kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

commit 5034c9fec10ff9310f26480efcf37a406ef7cb54
parent 2d3f151fe1bae1c9d4b55870b651e66117bf1cfd
Author: Kyle Barlow <kb@kylebarlow.com>
Date:   Sun, 19 Apr 2026 18:04:55 -0700

Add Letter Find game, replacing Typing for Now scaffold

Kid-focused letter recognition game: a random A-Z letter is shown
large and spoken; correct keypress triggers confetti, a C-E-G chime,
and a diatonic harmony layer locked to the background chiptune loop;
wrong keypress plays a bong + descending chromatic lick. Responsive
full-viewport layout via Phaser Scale.RESIZE. Separate Music/SFX mute
buttons. Background music via Tone.js (square-wave melody + triangle
bass, 128 BPM). Folder and thumbnail renamed from typing → letter-find.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Diffstat:
Rassets/thumbnails/typing.svg -> assets/thumbnails/letter-find.svg | 0
Agames/letter-find/game.js | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Agames/letter-find/index.html | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dgames/typing/game.js | 96-------------------------------------------------------------------------------
Dgames/typing/index.html | 25-------------------------
Mjs/main.js | 2+-
6 files changed, 437 insertions(+), 122 deletions(-)

diff --git a/assets/thumbnails/typing.svg b/assets/thumbnails/letter-find.svg diff --git a/games/letter-find/game.js b/games/letter-find/game.js @@ -0,0 +1,377 @@ +const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; +const TINTS = [0xE63946, 0x1D7CF2, 0xFFD23F, 0x2EC4B6]; +const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +class LetterFindScene extends Phaser.Scene { + constructor() { + super({ key: 'LetterFindScene' }); + this.targetChar = null; + this.targetColor = null; + this.targetText = null; + this.instrText = null; + this.guessTexts = []; + this.accepting = false; + this.firstRound = true; + this.audioCtx = null; + this.musicMuted = false; + this.sfxMuted = false; + this.musicReady = false; + this.bgGain = null; + this.melodySynth = null; + this.bassSynth = null; + this.jingleSynth = null; + this.failSynth = null; + this.successStepsLeft = 0; + this.seqStep = 0; + } + + preload() { + const g = this.make.graphics({ add: false }); + g.fillStyle(0xffffff); + g.fillRect(0, 0, 8, 8); + g.generateTexture('sq', 8, 8); + g.destroy(); + } + + create() { + const W = this.scale.width; + const H = this.scale.height; + + this.instrText = this.add.text(W / 2, 0, 'Find and press the matching letter', { + fontFamily: 'Fredoka, sans-serif', + color: '#888888', + }).setOrigin(0.5, 0.5); + + this.targetText = this.add.text(W / 2, 0, '', { + fontFamily: 'Fredoka, sans-serif', + fontStyle: 'bold', + color: '#ffffff', + }).setOrigin(0.5, 0.5); + + this.scale.on('resize', this.onResize, this); + this.input.keyboard.on('keydown', this.onKey, this); + + document.getElementById('btn-music').addEventListener('click', () => this.toggleMusic()); + document.getElementById('btn-sfx').addEventListener('click', () => this.toggleSfx()); + + this.newTarget(); + this.applyLayout(W, H); + } + + applyLayout(W, H) { + const instrSize = Math.max(16, Math.floor(H * 0.055)); + const targetSize = Math.max(48, Math.floor(H * 0.44)); + + this.instrText.setStyle({ fontSize: instrSize + 'px' }); + this.instrText.setPosition(W / 2, H * 0.11); + + this.targetText.setStyle({ fontSize: targetSize + 'px' }); + this.targetText.setPosition(W / 2, H * 0.44); + + this.reflowGuesses(W, H); + } + + onResize(gameSize) { + this.applyLayout(gameSize.width, gameSize.height); + } + + newTarget() { + let char, color; + do { char = CHARS[Phaser.Math.Between(0, CHARS.length - 1)]; } + while (char === this.targetChar); + + const available = COLORS.filter(c => c !== this.targetColor); + color = Phaser.Math.RND.pick(available); + + this.targetChar = char; + this.targetColor = color; + this.targetText.setText(char).setColor(color).setScale(0.3); + + this.tweens.add({ + targets: this.targetText, + scaleX: 1, scaleY: 1, + ease: 'Back.Out', + duration: 200, + onComplete: () => { this.accepting = true; }, + }); + + if (this.firstRound) { + this.speak('find the letter. ' + char); + this.firstRound = false; + } else { + this.speak(char); + } + } + + onKey(event) { + if (!this.accepting) return; + if (event.key.length !== 1 || !/[a-z]/i.test(event.key)) return; + + this.initMusic(); + + const pressed = event.key.toUpperCase(); + if (pressed === this.targetChar) { + this.onCorrect(); + } else { + this.onWrong(pressed); + } + } + + onCorrect() { + this.accepting = false; + this.playSuccess(); + this.playSuccessJingle(); + this.burstConfetti(); + + this.guessTexts.forEach(obj => { + this.tweens.killTweensOf(obj); + obj.destroy(); + }); + this.guessTexts = []; + + this.time.delayedCall(950, () => { + this.newTarget(); + const { width: W, height: H } = this.scale; + this.applyLayout(W, H); + }); + } + + onWrong(char) { + this.playBong(); + this.playFailLick(); + this.addGuessLetter(char); + } + + addGuessLetter(char) { + const { width: W, height: H } = this.scale; + const size = Math.max(20, Math.floor(H * 0.12)); + + const text = this.add.text(W / 2, H * 0.76, char, { + fontFamily: 'Fredoka, sans-serif', + fontSize: size + 'px', + fontStyle: 'bold', + color: '#cc4444', + }).setOrigin(0.5).setAlpha(0).setScale(0.3); + + this.tweens.add({ + targets: text, + scaleX: 1, scaleY: 1, alpha: 1, + ease: 'Back.Out', + duration: 150, + }); + + this.guessTexts.push(text); + this.reflowGuesses(W, H); + + this.time.delayedCall(1200, () => { + if (!text.active) return; + this.tweens.add({ + targets: text, + alpha: 0, scaleX: 0.2, scaleY: 0.2, + duration: 200, + onComplete: () => { + text.destroy(); + this.guessTexts = this.guessTexts.filter(t => t !== text); + this.reflowGuesses(this.scale.width, this.scale.height); + }, + }); + }); + } + + reflowGuesses(W, H) { + const n = this.guessTexts.length; + if (n === 0) return; + const size = Math.max(20, Math.floor(H * 0.12)); + const spacing = size * 1.15; + const startX = W / 2 - ((n - 1) * spacing) / 2; + this.guessTexts.forEach((obj, i) => { + obj.setPosition(startX + i * spacing, H * 0.76); + obj.setStyle({ fontSize: size + 'px' }); + }); + } + + burstConfetti() { + const { width: W, height: H } = this.scale; + const emitter = this.add.particles(W / 2, H * 0.44, 'sq', { + speed: { min: 100, max: 350 }, + angle: { min: 0, max: 360 }, + scale: { start: 1.2, end: 0 }, + gravityY: 500, + lifespan: 900, + tint: TINTS, + explode: true, + quantity: 45, + }); + this.time.delayedCall(1050, () => { if (emitter.active) emitter.destroy(); }); + } + + // ── Music ──────────────────────────────────────────────────────────── + + initMusic() { + if (this.musicReady) return; + this.musicReady = true; + + // Single gain node controls all Tone.js audio (the music mute target) + this.bgGain = new Tone.Gain(1).toDestination(); + + // Background melody: square wave = chiptune feel + this.melodySynth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: 'square' }, + envelope: { attack: 0.01, decay: 0.05, sustain: 0.25, release: 0.08 }, + }).connect(this.bgGain); + this.melodySynth.volume.value = -18; + + // Bass: softer triangle under the melody + this.bassSynth = new Tone.Synth({ + oscillator: { type: 'triangle' }, + envelope: { attack: 0.02, decay: 0.12, sustain: 0.4, release: 0.2 }, + }).connect(this.bgGain); + this.bassSynth.volume.value = -22; + + // Jingle synth: triangle for a bell-like overlay on success + this.jingleSynth = new Tone.Synth({ + oscillator: { type: 'triangle' }, + envelope: { attack: 0.01, decay: 0.08, sustain: 0.3, release: 0.15 }, + }).connect(this.bgGain); + this.jingleSynth.volume.value = -20; + + // Fail synth: square, brief chromatic stumble on wrong guess + this.failSynth = new Tone.Synth({ + oscillator: { type: 'square' }, + envelope: { attack: 0.01, decay: 0.06, sustain: 0.15, release: 0.06 }, + }).connect(this.bgGain); + this.failSynth.volume.value = -18; + + // Happy 2-bar melody loop in C major (8th notes, 16 steps) + const melody = [ + 'C5','E5','G5','A5','G5','E5','C5','E5', + 'F5','A5','C6','A5','G5','B4','C5','G4', + ]; + // Diatonic thirds above each melody note — played as harmony on success + const harmony = [ + 'E5','G5','B5','C6','B5','G5','E5','G5', + 'A5','C6','E6','C6','B5','D5','E5','B4', + ]; + new Tone.Sequence((time, note) => { + const step = this.seqStep % melody.length; + this.seqStep++; + this.melodySynth.triggerAttackRelease(note, '16n', time); + if (this.successStepsLeft > 0) { + this.jingleSynth.triggerAttackRelease(harmony[step], '16n', time); + this.successStepsLeft--; + } + }, melody, '8n').start(0); + + // Bass roots on beats 1 of each bar + const bass = [ + 'C3',null,null,null,null,null,null,null, + 'F2',null,null,null,'G2',null,null,null, + ]; + new Tone.Sequence((time, note) => { + if (note) this.bassSynth.triggerAttackRelease(note, '4n', time); + }, bass, '8n').start(0); + + Tone.Transport.bpm.value = 128; + this.bgGain.gain.value = this.musicMuted ? 0 : 1; + + Tone.start().then(() => Tone.Transport.start()); + } + + // Arms the harmony layer for the next 8 melody steps + playSuccessJingle() { + if (!this.musicReady) return; + this.successStepsLeft = 8; + } + + // Descending chromatic stumble — contrasts with the low bong + playFailLick() { + if (!this.musicReady) return; + const start = Tone.Transport.nextSubdivision('8n'); + const s = 60 / 128 / 4; + ['E4','Eb4','D4','Db4'].forEach((note, i) => { + this.failSynth.triggerAttackRelease(note, '16n', start + i * s); + }); + } + + toggleMusic() { + this.musicMuted = !this.musicMuted; + if (this.bgGain) this.bgGain.gain.value = this.musicMuted ? 0 : 1; + const btn = document.getElementById('btn-music'); + btn.textContent = 'Music: ' + (this.musicMuted ? 'OFF' : 'ON'); + btn.classList.toggle('muted', this.musicMuted); + } + + toggleSfx() { + this.sfxMuted = !this.sfxMuted; + const btn = document.getElementById('btn-sfx'); + btn.textContent = 'SFX: ' + (this.sfxMuted ? 'OFF' : 'ON'); + btn.classList.toggle('muted', this.sfxMuted); + } + + // ── WebAudio SFX ───────────────────────────────────────────────────── + + getAudioCtx() { + if (!this.audioCtx) { + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + } + return this.audioCtx; + } + + playBong() { + if (this.sfxMuted) return; + try { + const ctx = this.getAudioCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.setValueAtTime(160, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.3); + gain.gain.setValueAtTime(0.35, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.4); + } catch (_) {} + } + + playSuccess() { + if (this.sfxMuted) return; + try { + const ctx = this.getAudioCtx(); + const notes = [523, 659, 784]; // C5, E5, G5 + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.type = 'sine'; + const t = ctx.currentTime + i * 0.08; + osc.frequency.setValueAtTime(freq, t); + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.3, t + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28); + osc.start(t); + osc.stop(t + 0.3); + }); + } catch (_) {} + } + + speak(char) { + if (!window.speechSynthesis) return; + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(new SpeechSynthesisUtterance(char)); + } +} + +new Phaser.Game({ + type: Phaser.AUTO, + parent: 'game-container', + backgroundColor: '#1A1A2E', + scene: LetterFindScene, + scale: { + mode: Phaser.Scale.RESIZE, + width: '100%', + height: '100%', + }, +}); diff --git a/games/letter-find/index.html b/games/letter-find/index.html @@ -0,0 +1,59 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Letter Find — KGames</title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap" rel="stylesheet"> + <style> + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { height: 100%; overflow: hidden; } + body { + background: #1A1A2E; + font-family: 'Fredoka', sans-serif; + display: flex; + flex-direction: column; + } + nav { width: 100%; padding: 0.6rem 1.25rem; flex-shrink: 0; } + nav a { color: #aaa; text-decoration: none; font-size: 1rem; } + nav a:hover { color: #fff; } + #game-container { flex: 1; width: 100%; } + + #audio-controls { + position: fixed; + top: 0.45rem; + right: 1rem; + display: flex; + gap: 0.4rem; + z-index: 10; + } + #audio-controls button { + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.18); + color: #bbb; + font-family: 'Fredoka', sans-serif; + font-size: 0.85rem; + padding: 0.25rem 0.65rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + user-select: none; + } + #audio-controls button:hover { background: rgba(255,255,255,0.16); color: #fff; } + #audio-controls button.muted { color: #555; border-color: rgba(255,255,255,0.08); } + </style> +</head> +<body> + <nav><a href="../../index.html">&larr; Back to KGames</a></nav> + <div id="audio-controls"> + <button id="btn-music">Music: ON</button> + <button id="btn-sfx">SFX: ON</button> + </div> + <div id="game-container"></div> + <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> + <script src="https://cdn.jsdelivr.net/npm/tone@14.7.77/build/Tone.js"></script> + <script src="game.js"></script> +</body> +</html> diff --git a/games/typing/game.js b/games/typing/game.js @@ -1,96 +0,0 @@ -const COLORS = ['#E63946', '#1D7CF2', '#FFD23F', '#2EC4B6']; -const MAX_LETTERS = 11; -const LETTER_SPACING = 68; - -class TypingScene extends Phaser.Scene { - constructor() { - super({ key: 'TypingScene' }); - this.letters = []; - } - - create() { - this.add.text(400, 50, 'Type any key!', { - fontFamily: 'Fredoka, sans-serif', - fontSize: '26px', - color: '#aaaaaa', - }).setOrigin(0.5); - - this.add.text(400, 570, 'Backspace or Esc to clear', { - fontFamily: 'Fredoka, sans-serif', - fontSize: '18px', - color: '#555577', - }).setOrigin(0.5); - - this.input.keyboard.on('keydown', (event) => { - if (event.key === 'Escape' || event.key === 'Backspace') { - this.clearLetters(); - return; - } - if (event.key.length !== 1) return; - this.addLetter(event.key.toUpperCase()); - this.speak(event.key); - }); - } - - addLetter(char) { - if (this.letters.length >= MAX_LETTERS) this.clearLetters(); - - const idx = this.letters.length; - const color = COLORS[idx % COLORS.length]; - - const text = this.add.text(0, 300, char, { - fontFamily: 'Fredoka, sans-serif', - fontSize: '80px', - fontStyle: 'bold', - color, - }).setOrigin(0.5); - - text.setScale(0.3); - this.letters.push(text); - this.reflow(); - - this.tweens.add({ - targets: text, - scaleX: 1, - scaleY: 1, - ease: 'Back.Out', - duration: 180, - }); - } - - reflow() { - const n = this.letters.length; - const totalW = (n - 1) * LETTER_SPACING; - const startX = 400 - totalW / 2; - this.letters.forEach((obj, i) => { obj.x = startX + i * LETTER_SPACING; }); - } - - clearLetters() { - this.letters.forEach(obj => { - this.tweens.add({ - targets: obj, - alpha: 0, - scaleX: 0.2, - scaleY: 0.2, - duration: 150, - onComplete: () => obj.destroy(), - }); - }); - this.letters = []; - } - - speak(letter) { - if (!window.speechSynthesis) return; - window.speechSynthesis.cancel(); - window.speechSynthesis.speak(new SpeechSynthesisUtterance(letter)); - } -} - -new Phaser.Game({ - type: Phaser.AUTO, - width: 800, - height: 600, - parent: 'game-container', - backgroundColor: '#1A1A2E', - scene: TypingScene, -}); diff --git a/games/typing/index.html b/games/typing/index.html @@ -1,25 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Typing for Now — KGames</title> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;600&display=swap" rel="stylesheet"> - <style> - * { margin: 0; padding: 0; box-sizing: border-box; } - body { background: #1A1A2E; font-family: 'Fredoka', sans-serif; display: flex; flex-direction: column; align-items: center; min-height: 100vh; } - nav { width: 100%; padding: 0.75rem 1.25rem; } - nav a { color: #aaa; text-decoration: none; font-size: 1rem; } - nav a:hover { color: #fff; } - #game-container { display: flex; justify-content: center; } - </style> -</head> -<body> - <nav><a href="../../index.html">&larr; Back to KGames</a></nav> - <div id="game-container"></div> - <script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script> - <script src="game.js"></script> -</body> -</html> diff --git a/js/main.js b/js/main.js @@ -1,5 +1,5 @@ const GAMES = [ - { slug: 'typing', title: 'Typing for Now', thumb: 'assets/thumbnails/typing.svg', status: 'live' }, + { slug: 'letter-find', title: 'Letter Find', thumb: 'assets/thumbnails/letter-find.svg', status: 'live' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' }, { slug: null, title: 'Coming Soon', thumb: 'assets/thumbnails/placeholder.svg', status: 'placeholder' },