EECS 498 APSD P1
Spelling Bee Rubric
Overview
- Letter Representation
- Game State Representation
- Abstraction and Helper Functions
- Initialization of Game State
- Decoupled File I/O and Command-Line Arguments
- Decoupled User Interaction
- Main Game Loop and Command Handling
- Word Evaluation Implementation
- Printing the Board and Shuffling
- Meaningful Variable and Function Names
1 Representation of Puzzle Letters
The 7 letters used to configure the puzzle should be represented clearly, distinguishing the center letter from the outer letters. The dictionary of valid words should be represented as a set for efficient lookup. The representation should be well-organized and use clear naming to differentiate the components. Additional arrays or sets may be used for convenience/efficiency as long as they are well-organized.
readonly should be used appropriately:
- Applied on all member references that do not need to change.
- Arrays that do not change should be represented as
readonly string[]orReadonlyArray<string>. - Sets that do not change should be represented as
ReadonlySet<string>.
// GOOD - center and outer letters are clearly separated
interface SpellingBeeConfig {
public readonly center_letter: string; // single character
public readonly outer_letters: readonly string[]; // length 6, unique letters
public readonly dictionary: ReadonlySet<string>; // valid words
// ALSO GOOD - examples of additonal members for convenience/efficiency
private readonly min_length: number; // prefer not to hardcode even though it's always 4
private readonly all_letters: readonly string[]; // length 7, includes center + outer
private readonly valid_letters: ReadonlySet<string>; // lookup for validating words
private readonly displayed_letters: string[]; // mutable array that may be shuffled
};
// NOT GOOD - ambiguous naming
interface SpellingBeeConfig {
public readonly center_letter: string;
public readonly letters: string[]; // all letters or only non-center letters?
//...
}
// NOT GOOD - mutable variables that don't need to change (regardless of public/private)
interface SpellingBeeConfig {
private center_letter: string; // can be changed after construction
//...
}
// NOT GOOD - dictionary as array (inefficient lookup)
interface SpellingBeeConfig {
public readonly dictionary: string[]; // inefficient to check if word is valid
//...
}
The configuration may be represented as a separate abstraction, or as part of the primary game abstraction.
2 Game State Representation
The game state should include:
- The total number of valid words and points possible for this puzzle (readonly, computed once at initialization)
- The words the player has found (may be a
Set<string>) - The player’s current point total (a number, updated as words are found)
// GOOD - clear representation with appropriate types
class SpellingBeeGame {
private readonly num_valid_words: number;
private readonly points_possible: number;
private readonly words_found: Set<string>;
private points: number; // running total, updated when words are found
}
// NOT GOOD - array for words found
// (As an exception, a separate array may be used to track the order of found words if desired)
class SpellingBeeGame {
private words_found: string[]; // inefficient, easy to accidentally add duplicates
// ...
}
// NOT GOOD - recomputing points from words found
class SpellingBeeGame {
private words_found: Set<string>;
public getPoints(): number {
// Recomputes every time - inefficient
return [...this.words_found]
.map(word => this.word_points(word))
.reduce((a, b) => a + b, 0);
}
}
The representation should use readonly appropriately:
- The words found array/set should be
readonlybut its contents are mutable (i.e. words can be added but we’re not going to replace the entire array/set). - The number of valid words and points possible are fixed for a given puzzle
- Words found and current points are mutable game state
3 Abstraction and Helper Functions
Several operations related to the game are worth abstracting into individual functions:
- Calculating the point value of a word
- Checking if a word is a pangram (uses all 7 letters)
- Shuffling the outer letters
- Checking and scoring words
- Printing the honeycomb board
These are relatively deep abstractions and will make the code easier to read and maintain (as opposed to a single large play_game() function with everything mixed together).
4 Initialization of Game State
After construction, the game object should be in a fully valid, playable state. Avoid designs where a game is constructed in a partially initialized state and then requires additional setup calls before it can be used. This can lead to bugs where the game is used before it’s ready.
// GOOD - game is fully initialized after construction
const game = new SpellingBeeGame(config, dictionary);
game.play_game(rl); // ready to play immediately
// ALSO GOOD - factory method handles loading
const game = SpellingBeeGame.create_with_dictionary_file(config, "words.txt");
game.play_game(rl);
// NOT GOOD - game requires additional setup after construction
const game = new SpellingBeeGame();
game.setLetters(centerLetter, outerLetters);
game.loadDictionary("words.txt"); // what if someone forgets this step?
game.play_game(rl);
If your game supports restarting (e.g. a reset() method), the initialization logic should not be duplicated between the constructor and the reset method. Duplicated code is easy to get out of sync - you fix a bug in one place and forget the other.
// OK - shared initialization helper
class SpellingBeeGame {
constructor(config: SpellingBeeConfig, dictionary: ReadonlySet<string>) {
this.config = config;
this.dictionary = dictionary;
this.initializeGameState();
}
public reset(): void {
this.initializeGameState(); // reuses the same logic
}
private initializeGameState(): void {
this.words_found = new Set<string>();
this.points = 0;
this.displayed_letters = [this.config.center_letter, ...this.config.outer_letters];
}
}
// NOT GOOD - duplicated initialization logic
class SpellingBeeGame {
constructor(config: SpellingBeeConfig, dictionary: ReadonlySet<string>) {
this.config = config;
this.dictionary = dictionary;
this.words_found = new Set<string>();
this.points = 0;
this.displayed_letters = [this.config.center_letter, ...this.config.outer_letters];
}
public reset(): void {
// Copy-pasted from constructor - easy to forget to update both!
this.words_found = new Set<string>();
this.points = 0;
this.displayed_letters = [this.config.center_letter, ...this.config.outer_letters];
}
}
That said, the simplest approach is likely to not support resetting at all unless you have a compelling need. At this point, it’s fine to just have the “driver” create a new game instance when starting over. This avoids the duplication problem entirely.
5 Decoupled File I/O and Command-Line Arguments
The game abstraction should be decoupled from command-line argument (CLA) parsing and file I/O. It should live a happy life, not having to worry about those details. This makes for a cleaner abstraction with several pragmatic benefits:
- Testability: Unit tests can create game instances with specific, controlled data without dependence on
process.argvor the file system. - Reusability: The game class can be used in different contexts (CLI, web app, GUI) without modification.
- Single Responsibility: The game class focuses on game logic, not I/O concerns.
For example:
// GOOD - main handles CLA parsing and file I/O
async function main() {
const args = process.argv.slice(2);
const letters = /* parse and validate command-line arg */;
const dictionary = loadDictionary("words.txt");
const config = {
center_letter: letters[0],
outer_letters: letters.slice(1),
min_length: 4,
dictionary: dictionary
};
const game = new SpellingBeeGame(config);
await game.play_game(rl);
}
class SpellingBeeGame {
constructor(config: SpellingBeeConfig) {
// initialize game state from pre-loaded data
}
}
// NOT GOOD - game class handles file I/O directly
class SpellingBeeGame {
constructor(letters: string, dictionary_file: string) {
this.dictionary = this.loadDictionary(dictionary_file); // file I/O in constructor
// ...
}
private loadDictionary(filename: string): Set<string> {
return new Set(fs.readFileSync(filename, "utf-8").split("\n"));
}
}
// NOT GOOD - game class handles CLA parsing
class SpellingBeeGame {
constructor() {
const letters = process.argv[2]; // depends on process.argv
// ...
}
}
// STILL NOT GOOD - game class depends on CLA format and parsing, even if not on process.argv directly
class SpellingBeeGame {
constructor(args: string[]) {
// parse command-line args
// initialize game state
}
}
However, it might feel like there should be a designated function that handles loading the dictionary, rather than just presuming “the main function” does it. In this case, a static factory methods could implement creating a game from CLAs, while keeping the constructor focused on the core initialization. For example:
class SpellingBeeGame {
// Constructor takes pre-parsed, validated data
// The dictionary may be included in the config or passed separately
private constructor(config: SpellingBeeConfig) {
// ...
}
// Factory method handles file I/O
public static createFromCommandLineArgs(args: string[]): SpellingBeeGame {
const letters = /* parse and validate args */;
const dictionary = loadDictionary("words.txt");
const config: SpellingBeeConfig = {
center_letter: letters[0],
outer_letters: letters.slice(1),
min_length: 4,
dictionary: dictionary
};
return new SpellingBeeGame(config);
}
}
6 Decoupled User Interaction
If your game class has a method that drives the game loop and interacts with the user (e.g. play_game()), it should receive the input facility as a parameter rather than creating it internally. This will allow a single interface instance to be shared across different games.
// GOOD - readline interface is passed in
class SpellingBeeGame {
public async play_game(rl: readline.Interface): Promise<void> {
// ...
const input = await rl.question("Enter a word: ");
// ...
}
}
// NOT GOOD - game creates its own readline interface
class SpellingBeeGame {
public async play_game(): Promise<void> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// ...
const input = await rl.question("Enter word: ");
// ...
}
}
By passing in the readline.Interface, the caller controls the input source. This makes it possible to have a main program that creates a single shared interface and passes it to whichever game the user selects.
You will also want to start thinking about how to completely decouple the user interface from the game logic itself, for example by having the game class provide methods to get the current state and to submit guesses, without any direct user interaction. This will make it easier to implement different user interfaces (CLI, GUI, web) in the future.
7 Main Game Loop and Command Handling
The main game loop should be clearly structured, with actions and commands (such as entering a word, shuffling, or quitting) handled in a straightforward and readable way. Avoid deep nesting by using early returns for invalid or special cases, and keep the main flow of the game easy to follow. Parsing of user input (such as trimming, lowercasing, and checking for special commands) should be separated from the core game logic, ideally using helper functions where appropriate.
// GOOD - clear structure, handles special commands and validates input
public async play_game(rl: readline.Interface): Promise<void> {
while (this.points < this.points_possible) {
this.print_board();
let guess = await rl.question("Enter a word: ");
guess = guess.trim().toLowerCase();
// Handle special commands first
if (guess === "_quit") {
console.log("Thanks for playing!");
return;
}
if (guess === "_shuffle") {
this.shuffle_outer_letters();
continue;
}
// Validate it's a single word (no spaces)
if (guess.includes(" ")) {
console.log("Please enter only a single word.");
continue;
}
this.handle_guess(guess);
}
}
// NOT GOOD - deeply nested structure
public async play_game(rl: readline.Interface): Promise<void> {
while (this.points < this.points_possible) {
const guess = await rl.question("Enter a word: ");
if (guess !== "_quit") {
if (guess !== "_shuffle") {
if (!guess.includes(" ")) {
if (guess.length >= 4) {
if (guess.includes(this.center_letter)) {
// finally handle the guess...
}
}
}
} else {
this.shuffle_outer_letters();
}
} else {
return;
}
}
}
Commands and user actions should be parsed and handled in a way that keeps the main game logic clean and easy to maintain. Special commands (like shuffling or quitting) should be handled up front, and the distinction between command parsing and word validation should be clear.
8 Word Evaluation Implementation
The logic for checking whether a word is valid and calculating its point value should be clean and well-structured, and deserves a helper function that can be called in multiple places (e.g. initially finding how many valid words are possible and checking individual player guesses).
Avoid combining the check for whether a word has already been played with the check for whether a word is valid according to the puzzle rules. Treating repeated words as a separate case (e.g. in the main game loop or a dedicated check) makes your word validity logic reusable for both up-front dictionary filtering and in-game guess checking, and avoids confusion about what counts as a “valid word” for the puzzle.
An elegant approach is to use a discriminated union for evaluation results:
// GOOD - clear evaluation with typed result
type InvalidWordReason =
| "too_short"
| "missing_center_letter"
| "invalid_letters"
| "not_in_dictionary";
type WordEvaluation =
| { kind: "valid" }
| { kind: "invalid"; reason: InvalidWordReason };
private evaluate_word(word: string): WordEvaluation {
if (word.length < this.min_length) {
return { kind: "invalid", reason: "too_short" };
}
// other invalid cases...
// but don't check for "already found" here, since
// it makes "word evaluation" behaviorally coupled
// to the current game state and this helper becomes
// less easily reusable.
return { kind: "valid" };
}
// Separate function for scoring (assumes word is already validated)
private word_points(word: string): number {
// ...
}
It would also be alright to combine the two. You could essentially attaching 0 points to invalid words and positive points to valid words, or you could omit the points property entirely from the invalid evaluation type if using a discriminated union. In any case, the point calculation should still be cleanly separated from the validation logic (even if they are in the same function).
In addition, messages for invalid words should be generated in a centralized way, rather than being scattered throughout the validation logic. They should be printed separately from the evaluation logic, without hardcoded console.log() calls inside the validation function (otherwise it’s hard to reuse this function for anything else without spamming the console).
// GOOD - centralized message generation from evaluation result
private invalid_word_message(reason: InvalidWordReason, word: string): string {
switch (reason) {
case "too_short":
return `"${word}" is too short. Must be >= ${this.min_length} letters.`;
case "missing_center_letter":
return `"${word}" does not include the center letter "${this.center_letter}".`;
case "invalid_letters":
return `"${word}" contains letters not in the puzzle.`;
case "not_in_dictionary":
return `"${word}" is not a valid word.`;
default:
return assertNever(reason);
}
}
// NOT GOOD - validation mixed into handle_guess with scattered messages
private handle_guess(word: string): void {
if (word.length < 4) {
console.log("Too short!");
return;
}
if (!word.includes(this.center_letter)) {
console.log("Missing center letter!");
return;
}
// ... more checks with more inline messages
// scoring also mixed in
let points = word.length === 4 ? 1 : word.length;
if (/* pangram check duplicated here */) {
points += 7;
}
}
It would be alright to combine messages into the evaluation result if desired, and this might mean you don’t need a different property to indicate the “reason” a guess was invalid - you could just make your game loop print the message directly from the evaluation result and likewise use the returned point value (assuming 0 is invalid).
However, separating the concerns of evaluation, point calculation, and message generation will generally lead to cleaner, more maintainable code if you need to change any of this in the future. Composing the different pieces together gives more flexibilty, and leveraging the type system to track the different kinds of evaluations can ensure the coupling between them is manageable (e.g. checking for exhaustiveness when generating messages based on evaluation reason).
9 Printing the Board and Shuffling
The code that prints the honeycomb board should be cleanly organized. The honeycomb pattern with letters inserted can be generated in several ways:
// GOOD - template-based approach with placeholders
const HONEYCOMB_TEMPLATE =
` ___
___/ 2 \\___ #words
/ 1 \\___/ 3 \\ #points
\\___/ 0 \\___/
/ 6 \\___/ 4 \\
\\___/ 5 \\___/
\\___/`;
public print_board(): void {
let filled = HONEYCOMB_TEMPLATE;
// Replace 0,1,2,... placeholders with actual letters
// (Make sure to use yellow for the center letter)
// Replace #words and #points with actual values
console.log(filled);
}
// ALSO GOOD - building the board line by line with clear structure
public print_board(): void {
const [c, l1, l2, l3, l4, l5, l6] = this.displayed_letters;
console.log(` ___`);
console.log(` ___/ ${l2} \\___ ${this.words_found.size} / ${this.num_valid_words} words`);
console.log(`/ ${l1} \\___/ ${l3} \\ ${this.points} / ${this.points_possible} points`);
console.log(`\\___/ ${c} \\___/`); // center letter
console.log(`/ ${l6} \\___/ ${l4} \\`);
console.log(`\\___/ ${l5} \\___/`);
console.log(` \\___/`);
}
// NOT GOOD
public print_board(): void {
let output = " ___\n";
output += " ___/ " + this.displayed_letters[2] + " \\___ ";
output += this.words_found.size + " / " + this.num_valid_words + " words\n";
// ... many more lines of fragile concatenation
console.log(output);
}
The shuffling of outer letters should be done in a clean way that clearly affects only the outer letters, leaving the center letter in place. A dedicated array of displayed_letters that is mutable (while the original configuration remains readonly) can make this straightforward. Other approaches are alright as long as they are clear and maintainable.
10 Meaningful Variable and Function Names
Names should be descriptive and convey intent. A reader should be able to understand what a variable holds or what a function does without reading its implementation.
// GOOD - clear, descriptive names
const words_found: Set<string>;
const points_possible: number;
function evaluate_word(word: string): WordEvaluation;
function is_pangram(word: string): boolean;
function word_points(word: string): number;
function shuffle_outer_letters(): void;
// NOT GOOD - vague or cryptic names
const found: Set<string>; // found what?
const total: number; // total of what?
function check(word: string): boolean; // check what?
function calc(word: string): number; // calculate what?
function shuffle(): void; // shuffle what?
Avoid abbreviations unless they are universally understood. Single-letter names are acceptable for loop indices or very short scopes, but not for class fields or function parameters with significant meaning. Local variables within short functions may use shorter names if the context is clear.
Function names should typically be verbs or verb phrases (evaluateWord, isPangram, shuffleOuterLetters), while variable names should be nouns or noun phrases (centerLetter, wordsFound, pointsPossible).