diff --git a/apps/frontend/src/features/content/word-banks/manager.ts b/apps/frontend/src/features/content/word-banks/manager.ts
index 5a9a084..693354e 100644
--- a/apps/frontend/src/features/content/word-banks/manager.ts
+++ b/apps/frontend/src/features/content/word-banks/manager.ts
@@ -2,5 +2,5 @@ import { wordBanks } from "@/features/content/word-banks/registry";
import type { WordBankId } from "@/features/content/word-banks/types";
export function getWordBank(wordBankId: WordBankId) {
- return wordBanks[wordBankId] ?? null;
+ return wordBanks[wordBankId];
}
diff --git a/apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx b/apps/frontend/src/features/games/falling-words/components/field.tsx
similarity index 83%
rename from apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx
rename to apps/frontend/src/features/games/falling-words/components/field.tsx
index 5ea9b6c..4fc8cd5 100644
--- a/apps/frontend/src/features/games/falling-words/components/falling-words-field.tsx
+++ b/apps/frontend/src/features/games/falling-words/components/field.tsx
@@ -5,17 +5,16 @@ import { Kbd } from "@/components/ui/kbd";
import type { GamePhase } from "../../core/types";
import type { FallingWord } from "../types";
-type FallingWordsFieldProps = {
+type FieldProps = {
ref?: (el: HTMLDivElement) => void;
words: FallingWord[];
currentInput: string;
focusedWordId: number | null;
phase: GamePhase;
- score: number;
onFieldClick: () => void;
};
-function FallingWordsField(props: FallingWordsFieldProps) {
+export function Field(props: FieldProps) {
return (
)}
- {props.phase === "game-over" && (
-
- )}
-
{props.words.map((word) => {
const isFocused = word.id === props.focusedWordId;
const isPrefixMatch =
@@ -118,5 +103,3 @@ function FallingWordsField(props: FallingWordsFieldProps) {
);
}
-
-export default FallingWordsField;
diff --git a/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx b/apps/frontend/src/features/games/falling-words/components/hud.tsx
similarity index 76%
rename from apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx
rename to apps/frontend/src/features/games/falling-words/components/hud.tsx
index 8052da2..538f728 100644
--- a/apps/frontend/src/features/games/falling-words/components/falling-words-hud.tsx
+++ b/apps/frontend/src/features/games/falling-words/components/hud.tsx
@@ -1,11 +1,11 @@
import { GameStat } from "../../core/components/game-stat";
-export type FallingWordsHudProps = {
+type HudProps = {
score: number;
typedValue: string;
};
-export function FallingWordsHud(props: FallingWordsHudProps) {
+export function Hud(props: HudProps) {
return (
diff --git a/apps/frontend/src/features/games/falling-words/use-falling-words-game.ts b/apps/frontend/src/features/games/falling-words/engine.ts
similarity index 80%
rename from apps/frontend/src/features/games/falling-words/use-falling-words-game.ts
rename to apps/frontend/src/features/games/falling-words/engine.ts
index 79e56d1..6b3110d 100644
--- a/apps/frontend/src/features/games/falling-words/use-falling-words-game.ts
+++ b/apps/frontend/src/features/games/falling-words/engine.ts
@@ -9,12 +9,8 @@ import {
import { getWordBank } from "@/features/content/word-banks/manager";
import type { WordBankId } from "@/features/content/word-banks/types";
-import type { DifficultyKey, GamePhase } from "../core/types";
-import type {
- DifficultyConfig,
- FallingWord,
- UseFallingWordsGameOptions,
-} from "./types";
+import type { DifficultyKey, GameId, GamePhase } from "../core/types";
+import type { DifficultyConfig, FallingWord } from "./types";
const difficultyConfigs: Record
= {
easy: { spawnIntervalMs: 1800, baseSpeed: 68, speedJitter: 20, gravity: 6 },
@@ -24,6 +20,14 @@ const difficultyConfigs: Record = {
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
+export type UseGameOptions = {
+ onComplete?: (result: {
+ gameId: GameId;
+ score: number;
+ difficulty: DifficultyKey;
+ }) => void;
+};
+
function createFallingWord(
id: number,
width: number,
@@ -59,9 +63,9 @@ function findExactMatch(words: FallingWord[], value: string) {
.sort((left, right) => right.y - left.y)[0];
}
-export function useFallingWordsGame(
+export function useEngine(
wordBankId: WordBankId,
- options: UseFallingWordsGameOptions = {},
+ options: UseGameOptions = {},
) {
const wordBank = getWordBank(wordBankId);
@@ -82,18 +86,15 @@ export function useFallingWordsGame(
const [currentInput, setCurrentInput] = createSignal("");
const [elapsedMs, setElapsedMs] = createSignal(0);
- const selectedDifficulty = createMemo(() => difficultyConfigs[difficulty()]);
+ const config = createMemo(() => difficultyConfigs[difficulty()]);
const score = createMemo(() => formatScore(elapsedMs()));
const focusedWordId = createMemo((prevFocusedWordId?: number | null) => {
const input = currentInput();
const words = activeWords();
- if (input.length === 0) {
- return null;
- }
+ if (input.length === 0) return null;
- // Keep focus sticky if the previously focused word is still a valid exact prefix match
if (prevFocusedWordId != null) {
const prevWord = words.find((w) => w.id === prevFocusedWordId);
if (prevWord && prevWord.text.startsWith(input)) {
@@ -101,7 +102,6 @@ export function useFallingWordsGame(
}
}
- // Try exact prefix match first
const exactPrefixCandidates = words
.filter((word) => word.text.startsWith(input))
.sort((left, right) => right.y - left.y);
@@ -110,8 +110,6 @@ export function useFallingWordsGame(
return exactPrefixCandidates[0]?.id ?? null;
}
- // If no exact prefix match, find the word that HAS the longest prefix match with currentInput
- // This allows for mistakes at the end of the input while keeping focus
let bestWordId = null;
let longestPrefix = 0;
let lowestY = -1;
@@ -126,14 +124,11 @@ export function useFallingWordsGame(
prefixLen++;
}
- if (prefixLen > 0) {
- if (prefixLen > longestPrefix) {
+ if (prefixLen > 0 && prefixLen >= longestPrefix) {
+ if (prefixLen > longestPrefix || word.y > lowestY) {
longestPrefix = prefixLen;
bestWordId = word.id;
lowestY = word.y;
- } else if (prefixLen === longestPrefix && word.y > lowestY) {
- bestWordId = word.id;
- lowestY = word.y;
}
}
}
@@ -145,7 +140,6 @@ export function useFallingWordsGame(
if (phase() === "running" && runStartTime > 0) {
return elapsedBeforeRun + (performance.now() - runStartTime);
}
-
return elapsedBeforeRun;
};
@@ -164,20 +158,16 @@ export function useFallingWordsGame(
}
};
- const focusInput = () => {
- inputRef?.focus();
- };
+ const focusInput = () => inputRef?.focus();
const spawnWord = () => {
- if (!wordBank || wordBank.words.length === 0) {
- return;
- }
+ if (!wordBank || wordBank.words.length === 0) return;
const nextWord = createFallingWord(
nextWordId,
fieldWidth(),
wordBank.words,
- selectedDifficulty(),
+ config(),
);
nextWordId += 1;
setActiveWords((current) => [...current, nextWord]);
@@ -189,9 +179,7 @@ export function useFallingWordsGame(
setPhase("idle");
setActiveWords([]);
setCurrentInput("");
- if (inputRef) {
- inputRef.value = "";
- }
+ if (inputRef) inputRef.value = "";
focusInput();
setElapsedMs(0);
lastFrameTime = 0;
@@ -217,7 +205,7 @@ export function useFallingWordsGame(
setPhase("game-over");
stopLoop();
setElapsedMs(finalElapsedMs);
- void options.onComplete?.({
+ options.onComplete?.({
gameId: "falling-words",
score: finalScore,
difficulty: difficulty(),
@@ -226,21 +214,14 @@ export function useFallingWordsGame(
const submitExactMatch = (value: string, requireFocusMatch = false) => {
const targetWord = findExactMatch(activeWords(), value);
- if (!targetWord) {
- return false;
- }
-
- if (requireFocusMatch && targetWord.id !== focusedWordId()) {
- return false;
- }
+ if (!targetWord) return false;
+ if (requireFocusMatch && targetWord.id !== focusedWordId()) return false;
setActiveWords((current) =>
current.filter((word) => word.id !== targetWord.id),
);
setCurrentInput("");
- if (inputRef) {
- inputRef.value = "";
- }
+ if (inputRef) inputRef.value = "";
return true;
};
@@ -249,7 +230,6 @@ export function useFallingWordsGame(
resetGame(nextDifficulty);
return;
}
-
setDifficulty(nextDifficulty);
};
@@ -285,23 +265,16 @@ export function useFallingWordsGame(
if (event.key === "Enter") {
event.preventDefault();
-
- if (phase() === "idle" || phase() === "game-over") {
- startGame();
- }
+ if (phase() === "idle" || phase() === "game-over") startGame();
}
};
const handleVisibilityChange = () => {
- if (document.hidden && phase() === "running") {
- endGame();
- }
+ if (document.hidden && phase() === "running") endGame();
};
const handleWindowBlur = () => {
- if (phase() === "running") {
- endGame();
- }
+ if (phase() === "running") endGame();
};
createEffect(() => {
@@ -315,7 +288,7 @@ export function useFallingWordsGame(
lastFrameTime = timestamp;
setElapsedMs(elapsedBeforeRun + (timestamp - runStartTime));
- const difficultyConfig = selectedDifficulty();
+ const difficultyConfig = config();
if (timestamp - lastSpawnTime >= difficultyConfig.spawnIntervalMs) {
spawnWord();
@@ -337,9 +310,8 @@ export function useFallingWordsGame(
const nextRotation =
word.rotation + word.angularVelocity * deltaSeconds;
- if (fieldHeight() > 0 && nextY >= fieldHeight() - 40) {
+ if (fieldHeight() > 0 && nextY >= fieldHeight() - 40)
hitBottom = true;
- }
return {
...word,
@@ -373,13 +345,8 @@ export function useFallingWordsGame(
setTimeout(updateFieldSize, 100);
focusInput();
- const observer = new ResizeObserver(() => {
- updateFieldSize();
- });
-
- if (fieldRef) {
- observer.observe(fieldRef);
- }
+ const observer = new ResizeObserver(() => updateFieldSize());
+ if (fieldRef) observer.observe(fieldRef);
window.addEventListener("blur", handleWindowBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
@@ -391,27 +358,29 @@ export function useFallingWordsGame(
});
});
- onCleanup(() => {
- stopLoop();
- });
+ onCleanup(stopLoop);
return {
- activeWords,
- currentInput,
- difficulty,
- focusedWordId,
- handleDifficultyChange,
- handleInput,
- handleKeyDown,
- phase,
- score,
- setInputRef: (element: HTMLInputElement) => {
- inputRef = element;
+ game: {
+ phase,
+ difficulty,
+ activeWords,
+ currentInput,
+ focusedWordId,
+ score,
},
- setFieldRef: (element: HTMLDivElement) => {
- fieldRef = element;
+ actions: {
+ handleDifficultyChange,
+ handleInput,
+ handleKeyDown,
+ setInputRef: (element: HTMLInputElement) => {
+ inputRef = element;
+ },
+ setFieldRef: (element: HTMLDivElement) => {
+ fieldRef = element;
+ },
+ focusInput,
},
- focusInput,
wordBank,
};
}
diff --git a/apps/frontend/src/features/games/falling-words/meta.ts b/apps/frontend/src/features/games/falling-words/meta.ts
index 462f9c9..1b90f5c 100644
--- a/apps/frontend/src/features/games/falling-words/meta.ts
+++ b/apps/frontend/src/features/games/falling-words/meta.ts
@@ -1,5 +1,5 @@
import type { GameModule } from "../core/types";
-import FallingWordsView from "./view";
+import View from "./view";
export const meta: GameModule = {
id: "falling-words",
@@ -8,5 +8,5 @@ export const meta: GameModule = {
defaultWordBankId: "english/core-1k",
difficultyKeys: ["easy", "medium", "hard"] as const,
minScores: { easy: 20, medium: 15, hard: 10 },
- View: FallingWordsView,
+ View,
};
diff --git a/apps/frontend/src/features/games/falling-words/types.ts b/apps/frontend/src/features/games/falling-words/types.ts
index 3a6cf30..1d99b23 100644
--- a/apps/frontend/src/features/games/falling-words/types.ts
+++ b/apps/frontend/src/features/games/falling-words/types.ts
@@ -1,5 +1,3 @@
-import type { DifficultyKey } from "../core/types";
-
export type DifficultyConfig = {
spawnIntervalMs: number;
baseSpeed: number;
@@ -17,13 +15,3 @@ export type FallingWord = {
rotation: number;
angularVelocity: number;
};
-
-type CompletedGameResult = {
- gameId: "falling-words";
- score: number;
- difficulty: DifficultyKey;
-};
-
-export type UseFallingWordsGameOptions = {
- onComplete?: (result: CompletedGameResult) => void | Promise;
-};
diff --git a/apps/frontend/src/features/games/falling-words/view.tsx b/apps/frontend/src/features/games/falling-words/view.tsx
index ed88ccd..faa0b4c 100644
--- a/apps/frontend/src/features/games/falling-words/view.tsx
+++ b/apps/frontend/src/features/games/falling-words/view.tsx
@@ -1,41 +1,24 @@
import { Show } from "solid-js";
-import { useAuthSession } from "@/features/auth/hooks";
-import { useCreateResultMutation } from "@/features/users/results/api";
-import { toast } from "@/lib/toast";
-
import type { GameViewProps } from "../core/types";
+import { useSubmitGameResult } from "../core/hooks";
import { DifficultySelector } from "../core/components/difficulty-selector";
import { GameMeta } from "../core/components/game-meta";
-import FallingWordsField from "./components/falling-words-field";
-import { FallingWordsHud as Hud } from "./components/falling-words-hud";
-import { useFallingWordsGame } from "./use-falling-words-game";
+import { GameOver } from "../core/components/game-over";
+import { Field } from "./components/field";
+import { Hud } from "./components/hud";
+import { useEngine } from "./engine";
import { meta } from "./meta";
-function FallingWordsView(props: GameViewProps) {
- const auth = useAuthSession();
- const createResultMutation = useCreateResultMutation();
- const game = useFallingWordsGame(props.wordBankId ?? meta.defaultWordBankId, {
- onComplete: (result) => {
- if (!auth.isAuthenticated()) {
- return;
- }
-
- const minimumScore = meta.minScores[result.difficulty];
+function View(props: GameViewProps) {
+ const saveResult = useSubmitGameResult(meta.minScores);
- if (result.score < minimumScore) {
- toast.info(
- `Result not saved. Test too short. Minimum score for ${result.difficulty} is ${minimumScore}.`,
- );
- return;
- }
-
- createResultMutation.mutate({
- gameId: result.gameId,
- score: result.score,
- difficulty: result.difficulty,
- });
- },
+ const {
+ game: gameState,
+ actions,
+ wordBank,
+ } = useEngine(props.wordBankId ?? meta.defaultWordBankId, {
+ onComplete: saveResult,
});
return (
@@ -43,44 +26,50 @@ function FallingWordsView(props: GameViewProps) {
-
+
);
}
-export default FallingWordsView;
+export default View;
diff --git a/apps/frontend/src/features/games/survival/components/survival-hud.tsx b/apps/frontend/src/features/games/survival/components/hud.tsx
similarity index 93%
rename from apps/frontend/src/features/games/survival/components/survival-hud.tsx
rename to apps/frontend/src/features/games/survival/components/hud.tsx
index 15ceba3..f9565de 100644
--- a/apps/frontend/src/features/games/survival/components/survival-hud.tsx
+++ b/apps/frontend/src/features/games/survival/components/hud.tsx
@@ -2,7 +2,7 @@ import { Index } from "solid-js";
import { Heart } from "./heart";
import { GameStat } from "../../core/components/game-stat";
-export type SurvivalHudProps = {
+type HudProps = {
health: number;
score: number;
wpm: number;
@@ -10,7 +10,7 @@ export type SurvivalHudProps = {
isTakingDamage?: boolean;
};
-export function SurvivalHud(props: SurvivalHudProps) {
+export function Hud(props: HudProps) {
return (
diff --git a/apps/frontend/src/features/games/survival/engine.ts b/apps/frontend/src/features/games/survival/engine.ts
index 36c1c07..ca8b956 100644
--- a/apps/frontend/src/features/games/survival/engine.ts
+++ b/apps/frontend/src/features/games/survival/engine.ts
@@ -4,7 +4,7 @@ import { createStore } from "solid-js/store";
import { getWordBank } from "@/features/content/word-banks/manager";
import type { WordBankId } from "@/features/content/word-banks/types";
-import type { DifficultyKey, GamePhase } from "../core/types";
+import type { DifficultyKey, GameId, GamePhase } from "../core/types";
import { getMetrics } from "../core/metrics";
import { randomWord } from "../core/utils";
@@ -21,7 +21,7 @@ const DAMAGE: Record = {
export type UseGameOptions = {
onComplete?: (result: {
- gameId: string;
+ gameId: GameId;
score: number;
difficulty: DifficultyKey;
}) => void;
diff --git a/apps/frontend/src/features/games/survival/view.tsx b/apps/frontend/src/features/games/survival/view.tsx
index b3cea16..fc44fb6 100644
--- a/apps/frontend/src/features/games/survival/view.tsx
+++ b/apps/frontend/src/features/games/survival/view.tsx
@@ -8,7 +8,7 @@ import { GameInput } from "../core/components/game-input";
import { GameMeta } from "../core/components/game-meta";
import { meta } from "./meta";
import { useEngine } from "./engine";
-import { SurvivalHud as Hud } from "./components/survival-hud";
+import { Hud } from "./components/hud";
import { Words } from "./components/words";
import "./animations.css";