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:
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">← 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">← 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' },