// go llm/game_test.go (1-299) // file: game_test.go package main import ( "strings" "testing" ) func TestAutoFall(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("I") game.grid = NewGrid() game.tetromino = tetromino tetromino.position = [2]int{0, 0} // Start from top left // Simulate the game loop manually without goroutines for test purposes canMove := true for canMove { canMove = game.grid.MoveTetromino(tetromino, "down") } finalPos := tetromino.position // Since the grid height is 20 and tetromino "I" in horizontal position counts 1, // the final position should be the row 19th if finalPos[0] != rows-1 { t.Errorf("Expected final position on row %d, but got %d", rows-1, finalPos[0]) } // Check that moving further down stops after landing canMove = game.grid.MoveTetromino(tetromino, "down") if canMove { t.Error("Expected no further downward movement at the grid bottom") } } func TestAutoFallWithCollision(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("O") game.grid = NewGrid() game.tetromino = tetromino tetromino.position = [2]int{17, 0} // Position it close to the bottom game.grid.cells[19][0] = "X" // Create a block to simulate collision game.grid.cells[19][1] = "X" // Simulate gravity falling canMove := true for canMove { canMove = game.grid.MoveTetromino(tetromino, "down") } finalPos := tetromino.position // Since there's a block on row 19, tetromino "O" should stop on row 17 if finalPos[0] != 17 { t.Errorf("Expected final position on row 17, but got %d", finalPos[0]) } // Ensure it doesn't move further due to collision canMove = game.grid.MoveTetromino(tetromino, "down") if canMove { t.Error("Expected tetromino to stop moving due to collision with settled blocks") } } // Test that a full row is properly detected and cleared func TestClearFullRow(t *testing.T) { grid := NewGrid() // Fill the entire row for j := 0; j < cols; j++ { grid.cells[10][j] = "X" } // Clear full rows grid.clearFullRows() // Check that row 10 is cleared for j := 0; j < cols; j++ { if grid.cells[10][j] != emptyCell { t.Errorf("expected row 10 to be empty, but found %s at col %d", grid.cells[10][j], j) } } // Ensure rows above are shifted correctly expectedEmptyRow := [cols]string{emptyCell, emptyCell, emptyCell, emptyCell, emptyCell, emptyCell, emptyCell, emptyCell, emptyCell, emptyCell} for i := 0; i < 10; i++ { if grid.cells[i] != expectedEmptyRow { t.Errorf("expected row %d to be shifted to empty, but got %v", i, grid.cells[i]) } } } // Test cascading clear of multiple rows func TestClearMultipleFullRows(t *testing.T) { grid := NewGrid() // Fill several rows for j := 0; j < cols; j++ { grid.cells[9][j] = "X" grid.cells[10][j] = "X" grid.cells[11][j] = "X" } grid.clearFullRows() // Check rows that should be cleared for i := 9; i <= 11; i++ { for j := 0; j < cols; j++ { if grid.cells[i][j] != emptyCell { t.Errorf("expected row %d to be empty, but found %s at col %d", i, grid.cells[i][j], j) } } } // Ensure rows above (0-8) are shifted to empty rows for i := 0; i < 9; i++ { for j := 0; j < cols; j++ { if grid.cells[i][j] != emptyCell { t.Errorf("expected row %d to be empty after shifting, but got %v", i, grid.cells[i][j]) } } } } func TestTetrominoLocking(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("I") game.grid = NewGrid() game.tetromino = tetromino tetromino.position = [2]int{rows - 1, 0} game.placeTetromino() if strings.TrimSpace(strings.Join(game.grid.cells[rows-1][:], "")) != "XXXX" { t.Error("Expected last row to be filled with 'XXXX'") } game.grid.clearFullRows() // Should not clear since it's only filled partway if game.grid.cells[rows-1][0] != "X" { t.Error("Expected 'X' still at bottom after locking") } } func TestPieceSpawnAfterLock(t *testing.T) { game := NewGame() // Simulate the tetromino moving down to the bottom game.spawnTetromino() // Initial spawn // Manually move tetromino to the bottom for game.grid.MoveTetromino(game.tetromino, "down") { // Continue moving down until it can no longer move } // Now lock the tetromino into the grid game.placeTetromino() firstPosition := game.tetromino.position // Spawn a new tetromino, ensure it's in a different position game.spawnTetromino() secondPosition := game.tetromino.position if firstPosition == secondPosition { t.Error("Expected new tetromino to spawn; positions should be different after locking") } if game.gameOver { t.Error("Did not expect game over immediately after spawning a new tetromino") } } func TestGameOverCondition(t *testing.T) { game := NewGame() // Simulate the spawn area as filled for j := 0; j < cols; j++ { game.grid.cells[0][j] = "X" } // Try to spawn a new tetromino and verify game over game.spawnTetromino() if !game.gameOver { t.Error("Expected game over when spawning into a filled area") } } func TestScoreOnLineClear(t *testing.T) { game := NewGame() // Manually fill rows to simulate line clear for i := 0; i < 4; i++ { for j := 0; j < cols; j++ { game.grid.cells[i][j] = "X" } } linesCleared := game.grid.clearFullRows() game.updateScore(linesCleared) expectedScore := 800 // Four lines clear if game.score != expectedScore { t.Errorf("Expected score %d, got %d", expectedScore, game.score) } } func TestMultipleLineClears(t *testing.T) { game := NewGame() // Fill two rows for i := 0; i < 2; i++ { for j := 0; j < cols; j++ { game.grid.cells[i][j] = "X" } } linesCleared := game.grid.clearFullRows() game.updateScore(linesCleared) expectedScore := 300 // Two lines clear if game.score != expectedScore { t.Errorf("Expected score %d, got %d", expectedScore, game.score) } // Clear two more lines for i := 0; i < 2; i++ { for j := 0; j < cols; j++ { game.grid.cells[i][j] = "X" } } linesCleared = game.grid.clearFullRows() game.updateScore(linesCleared) expectedScore += 300 // Accumulate new score if game.score != expectedScore { t.Errorf("Expected cumulative score %d, got %d", expectedScore, game.score) } } // TestLevelProgression checks if level increases after 10 lines are cleared func TestLevelProgression(t *testing.T) { game := NewGame() // Simulate clearing 10 lines for i := 0; i < 10; i++ { for j := 0; j < cols; j++ { game.grid.cells[i][j] = "X" } } linesCleared := game.grid.clearFullRows() game.updateLevel(linesCleared) if game.level != 2 { t.Errorf("Expected level 2 after clearing 10 lines, but got level %d", game.level) } } // TestIncreasedGravityAtHigherLevels verifies that the fall speed decreases as levels increase func TestIncreasedGravityAtHigherLevels(t *testing.T) { game := NewGame() game.level = 5 initialSpeed := game.currentFallSpeed() game.level = 6 newSpeed := game.currentFallSpeed() if newSpeed >= initialSpeed { t.Error("Expected fall speed to increase (duration decrease) as level increases") } } // TestGameOverAndRestart verifies that the game correctly detects game over and resets the game state func TestGameOverAndRestart(t *testing.T) { game := NewGame() // Simulate a filled grid for j := 0; j < cols; j++ { game.grid.cells[0][j] = "X" } // Attempt to spawn a new Tetromino to trigger game over game.spawnTetromino() if !game.gameOver { t.Error("Expected game over when spawning into a filled area") } // Simulate restart by calling reset function game.resetGame() if game.gameOver { t.Error("Expected game to reset, but gameOver state remains true") } if game.score != 0 || game.linesCleared != 0 || game.level != 1 { t.Error("Expected game state to reset, but some values did not reset") } }// go llm/game.go (1-330) // file: game.go package main import ( "fmt" "math/rand" "sync" "time" "github.com/gdamore/tcell" ) const ( pointsPerLine = 100 ) type Game struct { screen tcell.Screen grid *Grid tetromino *Tetromino quit chan struct{} gameOver bool softDrop bool score int linesCleared int level int message string messageTimer *time.Timer rng *rand.Rand status string // Add game status (e.g., "in game", "game over") } func NewGame() *Game { return NewGameWithScreen(nil) } func NewGameWithScreen(screen tcell.Screen) *Game { rng := rand.New(rand.NewSource(time.Now().UnixNano())) return &Game{ screen: screen, grid: NewGrid(), quit: make(chan struct{}), rng: rng, score: 0, linesCleared: 0, level: 1, status: "in game", // Initially the game is active } } // Listen for player input to move tetromino func (g *Game) ListenForInput(wg *sync.WaitGroup) { defer wg.Done() for { ev := g.screen.PollEvent() switch e := ev.(type) { case *tcell.EventKey: if e.Key() == tcell.KeyEscape { g.Stop() return } switch e.Key() { case tcell.KeyRune: if e.Rune() == 'p' || e.Rune() == 'P' { // Toggle between pause and in game status if g.status == "in game" { g.status = "paused" } else if g.status == "paused" { g.status = "in game" } g.Render() // Update display to reflect new status } } switch g.status { case "in game": switch e.Key() { case tcell.KeyLeft: g.grid.MoveTetromino(g.tetromino, "left") case tcell.KeyRight: g.grid.MoveTetromino(g.tetromino, "right") case tcell.KeyUp: // Use the Up arrow for rotation g.RotateTetromino() case tcell.KeyDown: g.softDrop = true case tcell.KeyRune: if e.Rune() == ' ' { // Detect space bar for hard drop g.hardDrop() } } case "game over": if e.Rune() == 'y' || e.Rune() == 'Y' { g.resetGame() } else { g.Stop() return } } } } } // Run executes the main game loop, handling gravity and rendering func (g *Game) Run(wg *sync.WaitGroup) { defer wg.Done() ticker := time.NewTicker(g.currentFallSpeed()) defer ticker.Stop() g.spawnTetromino() g.Render() for { select { case <-ticker.C: if g.status != "in game" { // If not in the "in game" state, just wait and rerender g.Render() continue } fallSpeed := g.currentFallSpeed() if g.softDrop { fallSpeed = 50 * time.Millisecond } ticker.Reset(fallSpeed) // Process the game state if it is active if !g.grid.MoveTetromino(g.tetromino, "down") { g.placeTetromino() g.spawnTetromino() } g.Render() g.softDrop = false case <-g.quit: return } } } // currentFallSpeed returns the fall speed based on the current level func (g *Game) currentFallSpeed() time.Duration { var baseDuration = 500 * time.Millisecond speedIncrease := 50 * time.Millisecond * time.Duration(g.level-1) if speedIncrease >= baseDuration { return 50 * time.Millisecond // Cap the speed to a reasonable minimum } return baseDuration - speedIncrease } // Render draws the current game state to the screen func (g *Game) Render() { if g.screen == nil { return } g.screen.Clear() // Draw the grid with double-width characters for y, row := range g.grid.cells { for x, cell := range row { char := ". " // Two dots for empty cells color := tcell.ColorGray // Default color for empty cells if cell != emptyCell { char = "[]" // Two block characters for occupied cells color = tcell.ColorWhite // Color for occupied cells } drawText(g.screen, x*2, y, char, color) // Multiply column by 2 for alignment with double characters } } // If game is in progress, draw the active tetromino using double-width characters if g.status == "in game" || g.status == "paused" { for i, row := range g.tetromino.GetCurrentShape() { for j, cell := range row { if cell == 1 { x := (g.tetromino.position[1] + j) y := g.tetromino.position[0] + i drawText(g.screen, x*2, y, "[]", tcell.ColorYellow) } } } } // Display score, level, and lines cleared scoreText := fmt.Sprintf("Score: %d Level: %d Lines: %d", g.score, g.level, g.linesCleared) drawText(g.screen, 0, rows+1, scoreText, tcell.ColorGreen) // Handle different messages based on game status switch g.status { case "game over": msgs := []string{"Game Over", "Play again? Y/n"} for i, msg := range msgs { drawText(g.screen, ((cols*2)-len(msg))/2, (rows/2)+i, msg, tcell.ColorRed) } case "paused": msgs := []string{"Paused", "Resume press 'P'"} for i, msg := range msgs { drawText(g.screen, ((cols*2)-len(msg))/2, (rows/2)+i, msg, tcell.ColorRed) } } // Display a temporary message if any if g.message != "" { drawText(g.screen, 0, rows+3, g.message, tcell.ColorRed) } g.screen.Show() } // drawText draws text at a specific location using the specified color func drawText(screen tcell.Screen, x, y int, text string, color tcell.Color) { style := tcell.StyleDefault.Foreground(color) for i, c := range text { screen.SetContent(x+i, y, c, nil, style) } } // resetGame resets the game state func (g *Game) resetGame() { g.grid = NewGrid() g.tetromino = nil g.gameOver = false g.score = 0 g.linesCleared = 0 g.level = 1 g.message = "" g.softDrop = false g.status = "in game" // Reset status to in game // Restart game loop, spawning a new tetromino first g.spawnTetromino() } // Spawn the next tetromino and check for game over func (g *Game) spawnTetromino() { tetrominoType := string(tetrominoTypes[g.rng.Intn(len(tetrominoTypes))]) tetromino, err := NewTetromino(tetrominoType) if err != nil { fmt.Println("Error:", err) return } tetromino.position = [2]int{0, cols/2 - len(tetromino.GetCurrentShape()[0])/2} // Check if spawn position is occupied if !g.grid.CanMove(tetromino, tetromino.position) { g.gameOver = true g.status = "game over" // Update status to game over } else { g.tetromino = tetromino } } // placeTetromino locks the tetromino in place and clears lines if possible func (g *Game) placeTetromino() { shape := g.tetromino.GetCurrentShape() pos := g.tetromino.position for i, row := range shape { for j, cell := range row { if cell == 1 { g.grid.cells[pos[0]+i][pos[1]+j] = "X" } } } linesCleared := g.grid.clearFullRows() g.updateScore(linesCleared) g.updateLevel(linesCleared) } // updateScore updates the score based on the number of lines cleared func (g *Game) updateScore(linesCleared int) { switch linesCleared { case 1: g.score += 100 case 2: g.score += 300 case 3: g.score += 500 case 4: g.score += 800 default: // No lines cleared, no score change } // fmt.Printf("Score: %d\n", g.score) // Debug message; use tcell for real display } // Update level and manage message display func (g *Game) updateLevel(linesCleared int) { g.linesCleared += linesCleared if g.linesCleared >= 10 { g.level++ g.linesCleared -= 10 g.message = fmt.Sprintf("Level Up! New Level: %d", g.level) g.setMessageTimer(2 * time.Second) // Display message for 2 seconds } } // Set a message to display temporarily func (g *Game) setMessageTimer(duration time.Duration) { if g.messageTimer != nil { g.messageTimer.Stop() } g.messageTimer = time.AfterFunc(duration, func() { g.message = "" g.Render() }) } // hardDrop instantly places the tetromino to the lowest possible position func (g *Game) hardDrop() { for g.grid.MoveTetromino(g.tetromino, "down") { } g.placeTetromino() if !g.gameOver { g.spawnTetromino() } g.Render() } func (g *Game) Stop() { g.quit <- struct{}{} close(g.quit) }// go llm/grid_test.go (1-88) package main import ( "strings" "testing" ) // TestNewGrid checks if the grid initializes correctly with empty cells. func TestNewGrid(t *testing.T) { grid := NewGrid() // Verify the grid is of the correct size if len(grid.cells) != rows { t.Errorf("Expected grid to have %d rows, got %d", rows, len(grid.cells)) } if len(grid.cells[0]) != cols { t.Errorf("Expected grid to have %d cols, got %d", cols, len(grid.cells[0])) } // Verify all cells are initialized to a space for i := range grid.cells { for j := range grid.cells[i] { if grid.cells[i][j] != " " { t.Errorf("Expected cell [%d][%d] to be a space, got '%s'", i, j, grid.cells[i][j]) } } } } // TestPrintGrid checks the output format of the Print method. func TestPrintGrid(t *testing.T) { grid := NewGrid() expectedLine := strings.Repeat("| ", cols) + "|" for i := 0; i < rows; i++ { rowOutput := "" for j := 0; j < cols; j++ { rowOutput += "|" + grid.cells[i][j] } rowOutput += "|" if rowOutput != expectedLine { t.Errorf("Grid line %d did not match expected pattern. Got: %v; Want: %v", i, rowOutput, expectedLine) } } } // TestMoveTetromino tests movement constraints against grid edges and obstacles func TestMoveTetromino(t *testing.T) { grid := NewGrid() tetromino, _ := NewTetromino("I") // Move down initially to test edge constraints success := grid.MoveTetromino(tetromino, "down") if !success { t.Fatal("expected to move down successfully") } // Try to move out of left bounds tetromino.position = [2]int{0, 0} // Reset position success = grid.MoveTetromino(tetromino, "left") if success { t.Fatal("expected failure moving left out of bounds") } // Try to move out of right bounds tetromino.position = [2]int{0, cols - len(tetromino.GetCurrentShape()[0])} success = grid.MoveTetromino(tetromino, "right") if success { t.Fatal("expected failure moving right out of bounds") } // Try to move out of bottom bounds tetromino.position = [2]int{rows - len(tetromino.GetCurrentShape()), 0} success = grid.MoveTetromino(tetromino, "down") if success { t.Fatal("expected failure moving down out of bounds") } // Place an obstacle and try to move into it grid.cells[3][0] = "X" // Making a block at (3, 0) tetromino.position = [2]int{2, 0} success = grid.MoveTetromino(tetromino, "down") if success { t.Fatal("expected failure moving into an occupied cell") } }// go llm/grid.go (1-139) package main import "fmt" const ( rows = 20 cols = 10 ) // Cell states in the grid const emptyCell = " " type Grid struct { cells [rows][cols]string } // NewGrid initializes a 20x10 grid with each cell set to empty. func NewGrid() *Grid { grid := &Grid{} for i := 0; i < rows; i++ { for j := 0; j < cols; j++ { grid.cells[i][j] = emptyCell // Using a space to denote an empty cell } } return grid } // Print displays the grid to the console in a structured format. func (g *Grid) Print() { for i := 0; i < rows; i++ { for j := 0; j < cols; j++ { fmt.Printf("|%s", g.cells[i][j]) } fmt.Println("|") // Close the row with a "|" } } // Check if a tetromino can move to a specified position func (g *Grid) CanMove(tetromino *Tetromino, newPos [2]int) bool { newShape := tetromino.GetCurrentShape() for i, row := range newShape { for j, cell := range row { if cell == 1 { x := newPos[0] + i y := newPos[1] + j if x < 0 || x >= rows || y < 0 || y >= cols { return false // Out of bounds } if g.cells[x][y] != emptyCell { return false // Collision with settled block } } } } return true } // Move the tetromino within the grid if possible func (g *Grid) MoveTetromino(tetromino *Tetromino, direction string) bool { var newPosition [2]int switch direction { case "left": newPosition = [2]int{tetromino.position[0], tetromino.position[1] - 1} case "right": newPosition = [2]int{tetromino.position[0], tetromino.position[1] + 1} case "down": newPosition = [2]int{tetromino.position[0] + 1, tetromino.position[1]} default: return false } if g.CanMove(tetromino, newPosition) { tetromino.position = newPosition return true } return false } // Check if a row is fully filled func (g *Grid) isRowFull(row int) bool { for _, cell := range g.cells[row] { if cell == emptyCell { return false } } return true } // Remove a row and shift all above rows down func (g *Grid) clearRow(row int) { // Shift all rows above the specified row down by one for i := row; i > 0; i-- { g.cells[i] = g.cells[i-1] } // Clear the top row after shifting for j := 0; j < cols; j++ { g.cells[0][j] = emptyCell } } // clearFullRows removes full rows and returns the number of rows cleared func (g *Grid) clearFullRows() int { linesCleared := 0 for i := 0; i < rows; i++ { if g.isRowFull(i) { g.clearRow(i) linesCleared++ } } return linesCleared } func (g *Grid) CanRotate(tetromino *Tetromino) bool { nextRotation := (tetromino.rotationState + 1) % len(tetromino.shape) shape := tetromino.shape[nextRotation] for i, row := range shape { for j, cell := range row { if cell == 1 { x := tetromino.position[0] + i y := tetromino.position[1] + j if x < 0 || x >= rows || y < 0 || y >= cols || g.cells[x][y] != emptyCell { return false // Out of bounds or collision } } } } return true } // Rotate attempts to rotate the active tetromino clockwise func (g *Game) RotateTetromino() { if g.grid.CanRotate(g.tetromino) { g.tetromino.Rotate() g.Render() } }// go llm/main.go (1-29) package main import ( "sync" "github.com/gdamore/tcell" ) func main() { screen, err := tcell.NewScreen() if err != nil { panic(err) } if err := screen.Init(); err != nil { panic(err) } defer screen.Fini() var wg sync.WaitGroup wg.Add(2) // Create a new random number generator with a seed based on the current time game := NewGameWithScreen(screen) go game.Run(&wg) go game.ListenForInput(&wg) wg.Wait() }// go llm/movement_test.go (1-90) package main import "testing" func TestLeftRightMovementBlockedAtWalls(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("I") game.tetromino = tetromino // Attempt to place tetromino at the left-most column and try to move left game.tetromino.position = [2]int{0, 0} canMoveLeft := game.grid.MoveTetromino(game.tetromino, "left") if canMoveLeft { t.Error("Expected tetromino to be blocked moving left at the wall") } // Attempt to place tetromino at the right-most column and try to move right rightMostPosition := cols - len(tetromino.GetCurrentShape()[0]) game.tetromino.position = [2]int{0, rightMostPosition} canMoveRight := game.grid.MoveTetromino(game.tetromino, "right") if canMoveRight { t.Error("Expected tetromino to be blocked moving right at the wall") } } func TestLeftRightMovementBlockedByBlocks(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("I") game.tetromino = tetromino // Place tetromino and block to the left, then try to move left game.tetromino.position = [2]int{1, 1} game.grid.cells[1][0] = "X" // Blocking left movement canMoveLeft := game.grid.MoveTetromino(game.tetromino, "left") if canMoveLeft { t.Error("Expected tetromino to be blocked moving left by another block") } // Place tetromino and block to the right, then try to move right game.tetromino.position = [2]int{1, 0} game.grid.cells[1][4] = "X" // Blocking right movement canMoveRight := game.grid.MoveTetromino(game.tetromino, "right") if canMoveRight { t.Error("Expected tetromino to be blocked moving right by another block") } } // TestSoftDropSpeedsUpGravity checks that the soft drop speeds up tetromino descent func TestSoftDropSpeedsUpGravity(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("I") game.tetromino = tetromino initialPosition := game.tetromino.position // Simulate holding the soft drop for faster descent game.softDrop = true for i := 0; i < 5; i++ { // let it fall for 5 ticks game.grid.MoveTetromino(game.tetromino, "down") } if game.tetromino.position[0] <= initialPosition[0] { t.Errorf("Expected tetromino to have moved downward with soft drop active, but got position %v", game.tetromino.position) } } // TestHardDropPlacesPieceInstantly checks that the hard drop places a tetromino instantly at the bottom func TestHardDropPlacesPieceInstantly(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("I") game.tetromino = tetromino game.hardDrop() // Tetromino should be locked immediately at the bottom expectedRow := rows - len(tetromino.GetCurrentShape()) // Lowest possible position if tetromino.position[0] != expectedRow { t.Errorf("Expected tetromino to hard-drop to bottom (row %d), but was %d", expectedRow, game.tetromino.position[0]) } // Verify the grid has the tetromino locked as expected for i, row := range tetromino.GetCurrentShape() { for j, cell := range row { if cell == 1 && game.grid.cells[expectedRow+i][tetromino.position[1]+j] != "X" { t.Errorf("Expected cell %v to be locked but was not", [2]int{expectedRow + i, tetromino.position[1] + j}) } } } }// go llm/tetromino_test.go (1-106) // file: tetromino_test.go package main import ( "testing" ) // TestTetrominoCreation verifies tetrominos are created correctly with defined shapes func TestTetrominoCreation(t *testing.T) { for tetrominoType, expectedShapes := range tetrominoShapes { tetromino, err := NewTetromino(tetrominoType) if err != nil { t.Fatalf("failed to create tetromino %s: %v", tetrominoType, err) } // Check each rotation state shape for state, expectedShape := range expectedShapes { tetromino.rotationState = state if got := tetromino.GetCurrentShape(); !equalShapes(got, expectedShape) { t.Errorf("expected shape for tetromino %s at rotation %d to be %v, got %v", tetrominoType, state, expectedShape, got) } } } } // TestTetrominoRotation checks if the rotation state updates correctly func TestTetrominoRotation(t *testing.T) { tetromino, _ := NewTetromino("T") if tetromino.rotationState != 0 { t.Errorf("expected initial rotation state to be 0, got %d", tetromino.rotationState) } // Rotate 4 times and ensure it cycles back to initial state for i := 1; i <= 3; i++ { tetromino.Rotate() if tetromino.rotationState != i { t.Errorf("expected rotation state after %d rotates to be %d, got %d", i, i, tetromino.rotationState) } } tetromino.Rotate() // This should bring it back to state 0 if tetromino.rotationState != 0 { t.Errorf("expected rotation state after back to 0, got %d", tetromino.rotationState) } } // Helper method to compare if two shapes are equal func equalShapes(a, b [][]int) bool { if len(a) != len(b) { return false } for i := range a { if len(a[i]) != len(b[i]) { return false } for j := range a[i] { if a[i][j] != b[i][j] { return false } } } return true } func TestRotationChangesShape(t *testing.T) { tetromino, _ := NewTetromino("T") originalShape := tetromino.GetCurrentShape() tetromino.Rotate() newShape := tetromino.GetCurrentShape() if &originalShape == &newShape { t.Error("Expected tetromino shape to change upon rotation") } } func TestRotationBlockedByWall(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("L") game.tetromino = tetromino // Place tetromino near the right wall tetromino.position = [2]int{0, cols - 1} canRotate := game.grid.CanRotate(tetromino) if canRotate { t.Error("Expected rotation to be blocked by wall, but it was not") } } func TestRotationBlockedByBlocks(t *testing.T) { game := NewGame() tetromino, _ := NewTetromino("L") game.tetromino = tetromino // Place a blocking block on rotation path tetromino.position = [2]int{1, 1} game.grid.cells[2][1] = "X" canRotate := game.grid.CanRotate(tetromino) if canRotate { t.Error("Expected rotation to be blocked by another block") } }// go llm/tetromino.go (1-83) // file: tetromino.go package main import "fmt" const tetrominoTypes = "IOTJLSZ" // Tetromino shape definitions var tetrominoShapes = map[string][][][]int{ "I": { {{1, 1, 1, 1}}, // 0 degrees {{1}, {1}, {1}, {1}}, // 90 degrees {{1, 1, 1, 1}}, // 180 degrees {{1}, {1}, {1}, {1}}, // 270 degrees }, "O": { {{1, 1}, {1, 1}}, // 0 degrees {{1, 1}, {1, 1}}, // 90 degrees {{1, 1}, {1, 1}}, // 180 degrees {{1, 1}, {1, 1}}, // 270 degrees }, "T": { {{0, 1, 0}, {1, 1, 1}}, // 0 degrees {{1, 0}, {1, 1}, {1, 0}}, // 90 degrees {{1, 1, 1}, {0, 1, 0}}, // 180 degrees {{0, 1}, {1, 1}, {0, 1}}, // 270 degrees }, "S": { {{0, 1, 1}, {1, 1, 0}}, // 0 degrees {{1, 0}, {1, 1}, {0, 1}}, // 90 degrees {{0, 1, 1}, {1, 1, 0}}, // 180 degrees {{1, 0}, {1, 1}, {0, 1}}, // 270 degrees }, "Z": { {{1, 1, 0}, {0, 1, 1}}, // 0 degrees {{0, 1}, {1, 1}, {1, 0}}, // 90 degrees {{1, 1, 0}, {0, 1, 1}}, // 180 degrees {{0, 1}, {1, 1}, {1, 0}}, // 270 degrees }, "J": { {{1, 0, 0}, {1, 1, 1}}, // 0 degrees {{1, 1}, {1, 0}, {1, 0}}, // 90 degrees {{1, 1, 1}, {0, 0, 1}}, // 180 degrees {{0, 1}, {0, 1}, {1, 1}}, // 270 degrees }, "L": { {{0, 0, 1}, {1, 1, 1}}, // 0 degrees {{1, 0}, {1, 0}, {1, 1}}, // 90 degrees {{1, 1, 1}, {1, 0, 0}}, // 180 degrees {{1, 1}, {0, 1}, {0, 1}}, // 270 degrees }, } // Tetromino represents a tetromino piece type Tetromino struct { shape [][][]int position [2]int rotationState int } // NewTetromino creates a new Tetromino of given type func NewTetromino(tType string) (*Tetromino, error) { shape, exists := tetrominoShapes[tType] if !exists { return nil, fmt.Errorf("invalid tetromino type: %s", tType) } return &Tetromino{ shape: shape, position: [2]int{0, 0}, // default position at top-left corner rotationState: 0, // default to 0 degrees }, nil } // Rotate changes the tetromino to its next rotation state func (t *Tetromino) Rotate() { t.rotationState = (t.rotationState + 1) % len(t.shape) } // GetCurrentShape returns the current shape of the tetromino based on its rotation func (t *Tetromino) GetCurrentShape() [][]int { return t.shape[t.rotationState] }This is code for this SPEC:
- Playfield Dimensions: Use a standard Tetris playfield of 10 columns by 20 rows (Tetris Guideline - TetrisWiki). This will be the active game area (the "glass") where pieces fall. The implementation can include an additional hidden buffer above the 20-row playfield for piece spawning, but only 20 rows are visible during play.
- Tetromino Set: Include the seven classic Tetris tetrominoes: I, O, T, L, J, S, and Z. Each piece is composed of four blocks in the standard configurations. Use distinct identities (or colors, if applicable in text mode) for each piece type for clarity.
- Game Loop Timing: Implement a fixed-time-step game loop for consistent updates. Each iteration of the loop should advance the game by a fixed delta time, ensuring uniform behavior regardless of system performance (Game Development Patterns and Architectures in JavaScript by Olayinka Atobiloye - Video recording). This stabilizes movement speed and timing (gravity, lock delay, etc.) across different machines. For example, you might target ~60 updates per second or a similar stable frame rate for logic updates.
- Frame Rendering: Even in a text-mode environment, separate the game state updates from rendering. Only update the game state on the fixed time step; rendering to the console can be done at the same rate or a lower rate if needed to improve performance. This separation ensures smooth piece movement and consistent input handling.
- Standalone Binary: The game must compile into a single standalone Go binary. Keep external dependencies minimal – use only what’s necessary for functionality. It’s acceptable to use an external library for improved terminal control or output (for example, libraries like
termbox-goortcellfor handling keyboard input and drawing characters on the console), but avoid heavy frameworks. The end result should be easy to distribute and run (just an executable with no complex setup).
- Left Arrow: Move the current piece one cell to the left, if space is available. Holding the key down should repeat the movement (you may implement autorepeat with a delay, or simply rely on the fixed game loop to move repeatedly if the key remains pressed).
- Right Arrow: Move the current piece one cell to the right, with similar behavior to the left arrow (and the same collision checks).
- Down Arrow (Soft Drop): Increase the fall speed of the current piece while the key is held. This is a soft drop, meaning the piece moves down faster than the normal gravity but not instantly. It should not instantly lock the piece; instead, it just accelerates the descent. Once released, the piece returns to normal falling speed. No additional score is typically awarded for soft drop (though some versions give 1 point per cell; this can be included as a minor detail if desired (Scoring in Tetris® — Tetris Mobile Help Center)).
- Up Arrow (Rotate): Rotate the current piece 90 degrees (typically clockwise) each time the key is pressed. If using a single rotate button, only one rotation direction is needed (clockwise by default). The rotation should respect the chosen rotation system (simple or SRS) for how the piece kicks or behaves on rotation.
- Spacebar (Hard Drop): Immediately drop the piece to the lowest possible position it can occupy (all the way down until it lands on another block or the floor). Upon a hard drop, the piece should lock into place instantly (bypassing any lock delay). Hard drop does not count as a move that resets lock delay – it forces immediate lock. Optionally, award points for hard dropping (many implementations give 2 points per cell dropped as incentive (Scoring in Tetris® — Tetris Mobile Help Center)).
- Optional Controls: Although not specified in the core requirements, consider implementing Pause (e.g., press 'P' to pause the game), which can freeze the game loop until unpaused. This is not mandatory but useful for a complete game experience. Input Handling Note: Because this is a console application, special care is needed to read arrow keys and other keys without the user pressing Enter. Use a library or terminal mode that captures key presses in real-time. Ensure that keys like arrows and space are captured reliably (these may come as escape sequences in a raw terminal). Also, make sure to disable line buffering in the console so that key presses are detected immediately.
The game should support two rotation systems, selectable via a configuration flag (e.g.,
useSRS = true/false). The default will be the simple rotation system for classic behavior, but advanced players can enable the Super Rotation System (SRS) for modern Tetris rotations.
- Simple Rotation System: A basic rotation implementation. When the player presses rotate (Up arrow), rotate the tetromino 90° clockwise about its reference point. In the simple system, if the rotation would cause the piece to overlap a wall or another block, the rotation is blocked (the piece stays in its original orientation). There are minimal or no “wall kicks” in this mode – essentially, it’s the classic NES-style rotation: pieces do not try to adjust position when rotating into a wall. This is easier to implement but less forgiving to the player. If desired, you can implement a basic wall kick for the simple system (for example, allow the piece to shift one cell away from the wall if a rotation is blocked), but this should be very limited compared to SRS.
- Super Rotation System (SRS): Implement the modern Tetris Guideline rotation system (Tetris Guideline - TetrisWiki). SRS defines a set of tests (wall kicks) that will try to offset a piece when a rotation doesn’t fit. For example, if a tetromino is up against a wall or on the floor, the game will attempt alternate positions (kicks) to allow the rotation (An Overview of Kicks in SRS | FOUR) (Tetris Guideline - TetrisWiki). The SRS logic includes a specific kick table for each piece and orientation change (note that the I tetromino has its own kick data, and other pieces share a common kick data in SRS). When using SRS:
- If a normal rotation is obstructed, attempt a sequence of offset moves (e.g., shift the piece one cell right, one cell left, one cell up, etc., in a specified order) and check if any of those placements result in a successful rotation without collision (An Overview of Kicks in SRS | FOUR).
- Implement the standard SRS kick tables for all pieces, including the unique kicks for the I piece and the wall kicks for O (the O tetromino in SRS technically doesn’t move on rotation since it’s a square).
- Ensure that all rotations are reversible; if a rotation is possible in one direction, the opposite rotation should also be possible from the resulting position (a property of SRS (Tetris Guideline - TetrisWiki)).
- Rotation Selection Flag: Provide a way to select between these rotation systems, e.g., a boolean config setting (
simpleRotation=trueas default). Internally, you might have two sets of rotation logic and wall kick data. The rest of the game (controls, game loop) can call the appropriate rotation function based on this flag. This allows easy switching for the user and also makes it straightforward to test both systems.- Spawn Orientation: When a new piece spawns at the top of the playfield, start it in a default orientation (typically spawn orientations are “flat” – e.g., the T, L, J spawn pointing upwards flat, the I piece spawns horizontal, etc., per guideline). For SRS compliance, use the standard spawn orientation (flat side down) (Tetris Guideline - TetrisWiki), but the simple system can use the same orientations for consistency. This detail ensures that pieces start in expected orientations which affects how they fit in the top of the playfield.
- Boundary Checks: The active tetromino must never move outside the bounds of the 10x20 playfield. Implement checks so that if a move or rotation would place any part of the piece beyond the left wall, right wall, or below the floor (bottom of the 20th row), that action is disallowed. Similarly, pieces should not be able to move above the top boundary during normal play (the top is only exceeded when a piece spawns or if the stack is high; see game over conditions).
- Collision with Settled Blocks: Keep track of the board grid with all settled/locked blocks. Before moving the active piece (translation or rotation), check the target cells. If any cell of the active piece’s new position would overlap a cell that is already occupied by a locked block, the movement/rotation is not allowed. This ensures pieces stack up but never overlap.
- Wall Kicks and Rotation Adjustments: When using SRS rotation, implement the wall kick checks as described in the rotation system section. The collision detection should work hand-in-hand with these kicks: test each offset position in the kick table for collisions, and rotate+move the piece to the first offset that is free. If none are free, the rotation fails (the piece stays in its original orientation) (An Overview of Kicks in SRS | FOUR). For the simple rotation mode, you might allow a very limited kick (like one upward shift if on floor, or one sideways shift if against a wall) or none at all, depending on how “classic” you want it. Document the chosen behavior clearly in code comments for future maintainers.
- Lock Delay: Implement a lock delay so pieces don’t freeze the instant they touch down on the bottom or stack. Lock delay is the time between a piece first contacting the ground and when it actually locks in place. For example, the official guideline uses a 0.5 second lock delay (Tetris Guideline | Tetris Wiki | Fandom). You can choose a similar duration (e.g. 500 milliseconds) as a starting point. During this delay, the player can still move or rotate the piece to try to fit it better. If the piece is moved or rotated such that it is no longer touching the ground, reset the lock delay timer for that piece. However, to prevent infinite stalling, consider a limit on how many times lock delay can be reset (guideline uses an infinite spin prevention where after certain moves the piece will force lock (Tetris Guideline - TetrisWiki), but a simple approach is fine for this project, e.g., allow a certain number of moves or a fixed total time).
- Gravity and Soft Drop Behavior: The piece should naturally fall one cell at a time according to a timer (gravity). At level 1 (or level 0), define a baseline gravity speed (e.g., one cell per X milliseconds). This will decrease (pieces fall faster) as the level increases. When the Down arrow is held, temporarily increase the gravity (e.g., 10× normal speed) to simulate soft drop. Ensure the collision detection still checks each step of the soft drop movement – if the piece is just one cell above the ground and the player holds Down, it should move that one cell and then stop (not pass through). The game loop may handle this by moving the piece multiple times per tick when soft dropping, or by reducing the interval until the next drop.
- Hard Drop and Lock: When the player hard drops a piece (Spacebar), calculate the lowest empty position the piece can occupy (you can do this by simulating downward movement until a collision is detected, or by scanning from the piece’s current position downwards). Place the piece there instantly and lock it immediately. Collision detection will be involved in finding that position but once found, you can lock without further checks since by definition directly above that was free and the cell below is occupied or floor. Hard drop should skip the lock delay (treat it as if the delay is zero for that drop).
- No Mid-Air Locking: Ensure that a piece only locks (becomes part of the settled stack) when either the player hard drops it or when it has been at rest at the bottom (or on other blocks) for the duration of the lock delay without player intervention. If the piece is still moving down or being moved/rotated by the player, it should remain active. Only when the conditions are met (touching ground + lock timer expired, or hard drop) do you convert the piece’s cells to settled blocks in the grid and generate a new piece.
- Line Clear Scoring: Implement the classic scoring system where clearing more lines at once yields higher scores. As a baseline, use the standard single/double/triple/Tetris values: clearing one line = 100 points, two lines = 300 points, three lines = 500 points, and four lines (a “Tetris”) = 800 points (Scoring in Tetris® — Tetris Mobile Help Center). These values can be multiplied by the current level (for a level-based score boost) or simply added as fixed points with level mainly affecting speed – decide based on desired difficulty progression. A common approach (from modern guideline) is to multiply base points by (Level + 1), but original NES Tetris used a level multiplier only for line clear points as shown above (which effectively is the same as Level+1 for their level count starting at 0). Choose one method and document it.
- Combo Bonuses: Reward consecutive line clears with combo points. A combo is when the player clears lines with successive pieces, without any piece in between that doesn’t clear a line. For example, if two pieces in a row each clear at least one line, that’s a 1-combo (also described as “combo count = 1”). In many Tetris games, each combo beyond the first adds an extra 50 points * combo count * level (Combo - TetrisWiki). You can implement a similar system: start a combo counter at 0, increment it each time a piece clears any lines, and reset it when a piece is placed without clearing a line. Each time the counter increases, award bonus points (e.g., 50 × combo count × level). This means clearing lines with back-to-back pieces can significantly increase score, encouraging risky continuous clears. (You may choose a simpler combo scoring if desired, but make sure the principle of increasing reward for consecutive clears is present.)
- Soft Drop and Hard Drop Scoring: Optionally, include a small score for using soft drop and hard drop, to incentivize faster play. A common rule is 1 point per cell for soft drop, and 2 points per cell for hard drop (Scoring in Tetris® — Tetris Mobile Help Center). This means if a piece is high up and the player hard-drops it down 15 rows, they get 30 points in addition to any line clear points. This is not a crucial gameplay element, but it is a nice touch that aligns with official scoring systems. If implemented, ensure these points are added immediately when the drop action occurs.
- Leveling Up: Increase the game’s level as the player clears lines. A typical scheme is to start at Level 1 (or 0) and increase the level every 10 lines cleared. For example, every 10 lines, increment the level by 1. Alternatively, use the Tetris guideline approach: fixed-goal of 10 lines per level up (Tetris Guideline | Tetris Wiki | Fandom). The level influences the gravity (fall speed) – higher levels mean faster piece drop. You might define a table of fall speeds per level or a formula (e.g., Level 1 might be 1 cell per 0.8 seconds, Level 2 one cell per 0.7 seconds, etc., approaching maybe a max speed of one cell per tick at very high levels). Make sure the progression feels fair – early levels should be comfortably slow, and by Level ~10+ it becomes challenging.
- Score Display: The game’s console output should include a status display showing the Score, Level, and Lines Cleared. Update these in real-time as the game progresses. For example, you might reserve a section of the terminal (above or beside the playfield) to show:
Level: 3 Score: 12500 Lines: 28This gives the player constant feedback on their progress. Keep the display updated every game loop or every time a value changes (line clear, score change, or level-up). In a text-mode game, you might print this at a fixed position using terminal control codes or just redraw the screen each frame.
- Back-to-Back (Optional): In modern Tetris, doing difficult moves back-to-back (like multiple Tetrises in a row) yields a bonus. This is an optional extension. If implemented, track if the last clear was a Tetris (or potentially a T-spin if you decide to include T-spin detection). If the player scores another Tetris without a single/double/triple in between, apply a back-to-back bonus (typically an extra 50% points for the Tetris). This can stack with combos. Since T-spins are not explicitly required by this spec, you can omit this or include it only if you add T-spin recognition.
- Top-Out Rule: The game ends when a new piece can no longer enter the playfield without collision. In practice, this means that when you try to spawn a new tetromino at the top of the playfield, its starting position is already partially occupied by existing blocks. This is the classic “block out” or top-out condition (Tetris Guideline | Tetris Wiki | Fandom). Implement this by checking at piece spawn time: if the spawn location of the piece overlaps any filled cell in the grid (or is above the top of the visible playfield), then the game is over.
- Lock Out: Another scenario for game over is if a piece locks into place such that part of it lies above the 20-row playfield. For example, if the stack is so high that even though the piece spawned, when it comes to rest it is sticking out of the top. This is effectively the same end result – the player can’t continue – so treat it as game over as well (Tetris Guideline | Tetris Wiki | Fandom).
- When game over is triggered, stop the game loop and display a “Game Over” message. Also, display the final score and maybe prompt if the player wants to play again or press a key to exit. Ensure that the terminal state is properly reset (if you changed modes for input capturing, etc.) so the user isn’t left with a broken terminal.
- After game over, if the player’s score is a new high score, make sure to update the high score file (see Persistence section) before exiting or restarting. Provide feedback like “New High Score!” if appropriate.
- High Score Saving: Maintain a record of high scores. At minimum, store the top score (and perhaps the level or lines corresponding to it, or timestamp). Each time a game ends, compare the player's score to the saved high score. If it's higher, update the record. Save this to a file on disk so that it persists between runs of the game. The file can be a simple JSON or even a plain text file. For example,
highscore.jsoncould store something like{ "score": 15000, "level": 5, "lines": 40 }. If you want to keep multiple high scores, you could extend this to a list of scores or even a small leaderboard.- Settings File: Provide a configuration file for user settings. This could include:
- Rotation System: whether to use SRS or simple rotation (as mentioned, a boolean flag).
- Initial Level/Speed: allow the player to start at a higher level or set a custom gravity. This is similar to classic Tetris where you can start at level 0 or higher for more challenge.
- Key Bindings: allow remapping of controls. For example, some players might prefer
WASDkeys or different rotate/drop keys. The config could let them specify which key corresponds to left, right, rotate, soft drop, hard drop, etc. Use key codes or characters to define this.- Sound or Visual Settings: If any (though likely not, in a text game). Possibly toggling sound if you add sound support via the console beep or external sound (not required). Or toggling ghost piece (a faint preview of where the piece will land) if you implement that.
- Use a standard format (JSON, TOML, YAML, or even .ini) for the config for easy parsing. Since this is Go, JSON is easy with the
encoding/jsonpackage, or you might use a library for TOML if you prefer a .toml file. Document the expected format with an example config file.- Config File Location: Decide where to store the config and high score files. Options:
- Current directory (simple, but if the game is run from different directories, the data might not persist where expected).
- User’s home directory (e.g.,
~/.tetrisrcand~/.tetris_scores). This is more consistent for user-specific config.- A dedicated config directory (e.g., on Linux,
~/.config/tetris/). For simplicity, storing in the current working directory or alongside the game binary is fine for a small project, but document this choice.- Default Settings: If no config file exists, start with sensible defaults (e.g., simple rotation, level 1 start, arrow keys for movement as specified, standard scoring, etc.). The game on first run can create a new config file with defaults. Provide a way (maybe a command-line flag or a menu option) to reset to defaults if needed.
- Persistent High Score Handling: Ensure file operations (read/write) have proper error handling (e.g., if the file is not found, handle it gracefully by starting fresh; if the file is corrupt or cannot be parsed, perhaps warn the user and reset it). When writing the high score, be careful to not interrupt the game if the disk write fails – you might log an error but still continue. Always close files properly to avoid data loss.
- Robust Input/Output Handling: The game should handle unexpected situations gracefully. For example, if the terminal window is resized or if an unsupported key is pressed, the game shouldn’t crash. Use Go’s error returns to catch issues when reading input or writing to the terminal. If using an external library for terminal I/O, use its functions (which often return errors) carefully – if an error is returned (say, terminal lost focus or an I/O issue), you might pause the game or attempt to reinitialize the input.
- Graceful Exit: Make sure to restore the terminal state on exit. If you put the terminal in raw mode (no echo, etc.), ensure that even if the game crashes or errors out, you capture that (maybe with
deferto reset terminal settings) so the user isn’t stuck with a non-responsive shell. This is part of error handling because an abnormal termination could otherwise leave the console in a bad state.- Logging: Implement a basic logging mechanism. For debugging during development (or for a “debug mode”), it’s useful to have logs of game events. You can use Go’s built-in
logpackage to write to a file (e.g., atetris.login the current directory) or tostderr. Log key events such as piece spawns, line clears, score updates, level-ups, and any errors or unusual conditions. This will help track down issues like pieces not rotating correctly or scores miscalculating.- Debug Mode: Consider a command-line flag like
--debugor a config setting that turns on additional debug output. In debug mode, you might draw additional info on the screen (like the bounding boxes, or the ghost piece for where it will land, or internal state info), or simply spam the log file with detailed state info each tick. This is invaluable for troubleshooting complex behaviors (for example, verifying that the lock delay reset logic works by logging the timer, or seeing the random sequence of pieces generated). Make sure debug mode is off by default so as not to overwhelm normal gameplay with unnecessary output.- Input Debugging: If players report that certain keys aren’t working (for example, some terminals might send different codes for arrows), having a debug mode that can print the key codes received can help diagnose and allow remapping.
- Error Messages: If a fatal error occurs (like failing to initialize the terminal, or config file permissions issues), print a clear error message to the console and exit rather than panicking with a stack trace. The message should guide the user or developer (“Error: Could not read config file. Using defaults.” or “Fatal: Terminal initialization failed. Ensure your terminal supports the required mode.”). This makes it easier to figure out what went wrong when running the game.
Develop a comprehensive test suite to cover the game’s functionality. Wherever possible, structure the code to separate pure logic from side effects (like rendering or OS-specific calls) so that core gameplay mechanics can be unit tested.
- Unit Tests for Game Logic: Create unit tests for the fundamental functions:
- Rotation: Given a piece in a certain position with certain surrounding blocks, test that rotating it results in the expected orientation and position (especially for SRS kicks). For example, place a T piece near a left wall and attempt a rotation; verify that with SRS enabled it “kicks” one cell to the right to fit (An Overview of Kicks in SRS | FOUR), and with simple rotation it fails to rotate (remains in original orientation). Test rotation at the bottom (floor kicks) as well.
- Movement and Collision: Test that moving a piece left/right stops at walls. Set up a scenario where blocks are at certain positions, and verify that the falling piece collides correctly and cannot move or rotate through them. Also test that soft drop moves multiple steps down until collision.
- Line Clearing: Construct scenarios of the grid where one or multiple full lines exist. Call the line-clear logic and assert that:
- The lines are removed.
- Blocks above those lines fall down the correct number of rows.
- The score increases appropriately based on how many lines were cleared.
- The line counter increases, and possibly the level increases if threshold reached. Write tests for single, double, triple, and quadruple line clears to ensure scoring and clearing behavior is correct.
- Scoring and Combos: Simulate sequences of clears to test score computation. For example, simulate a single line clear on level 1 and check that 100 points (times level multiplier if applied) were added. Then simulate a double line clear and verify 300 points added. Then simulate two line clears in a row (combo) and ensure the combo bonus was added. This can be done by calling the scoring function(s) directly with predetermined inputs (like linesCleared=2, comboCount=1, backToBack=false, level=3, etc.) and checking the returned score.
- Level Progression: Simulate clearing lines across level boundaries. For instance, if 10 lines = level up, simulate clearing 9 lines (no level up yet), then clear 1 line and verify that level increased by 1 and gravity speed changed. Also test multiple level increments (like clearing 20 lines quickly should increase two levels).
- Lock Delay Behavior: This one is trickier to unit test without a real time component, but you can abstract the lock delay into a function that gets called each tick with whether the piece is currently on the ground or not. Then simulate a sequence: piece touches ground at time=0 -> timer starts, at tick X simulate a rotate that lifts it off ground -> timer resets, etc., and verify that if no reset happens within the threshold the function would signal “lock now”. Essentially, test that the lock delay timer resets appropriately on movements and triggers lock after the correct duration of inactivity.
- Integration / Functional Tests: For aspects that involve interaction of multiple parts (or those that depend on real timing), consider an integration test or a controlled simulation:
- You can simulate a full small game by feeding a predetermined sequence of inputs to the game loop (this might require making the game loop able to accept an input script). For example, drop a piece in a known pattern and ensure after a series of moves a line clears and the game state (grid, score, level) matches expected values.
- Test the game over condition by programmatically filling the board to just below top and then dropping a piece that will cause top-out. Verify that the game sets a Game Over state.
- If possible, automate running the game in a headless mode (without real user input) for a few pieces to ensure no runtime errors happen. This could be done by abstracting input source to feed in a series of moves.
- Input Handling Tests: Testing actual keyboard inputs in an automated way can be tricky. Instead, refactor input handling so that it can be abstracted (e.g., an interface that yields “commands” or key presses). Then in tests, substitute a mock implementation that returns a sequence of desired inputs. This way you can simulate “Left, Left, Rotate, Down” and then check the piece’s position and orientation in the game state after those commands.
- File I/O Tests: Write tests for the config and high score persistence:
- Start with no config file (perhaps in a temp directory) and run the config load function – ensure it loads defaults.
- Then write a sample config file, run load, and verify that the in-game settings match what was in the file (e.g., if the config set leftKey to 'A', verify the game now interprets 'A' as left).
- For high score, simulate end-of-game scenarios: call a function to record high score with a given score, and ensure the file is created or updated with that score. Then simulate another game with a lower score and ensure the file remains with the higher score (if you keep only one). If you keep a list of high scores, test insertion in the correct order.
- Edge Case Tests:
- Spawn in Filled Row: Ensure that if the spawn location has filled blocks (which should only happen if the stack is extremely high), the game over triggers. You can simulate this by setting up the board state (grid) with blocks up to row 20 and then calling the spawn piece function in a test – it should detect the collision and return a game over condition.
- Rotation at Edges: Test rotating each piece in the corners of the playfield. E.g., put a J, L, or T piece at the extreme left column and try rotating (with SRS on, it should kick; with SRS off, it should fail). Similarly, test an I piece rotation at the wall (the I piece has special kicks in SRS – ensure they are correct).
- Maximum Combo: Simulate a long combo (if your logic allows, e.g., clear lines 5 pieces in a row) and verify the combo counter resets properly at a miss and that the scoring adds up for each step.
- Fast Inputs: Some players might press multiple keys quickly or at the same time. While real concurrency of inputs is not an issue (keyboard events are sequential), test scenarios like pressing rotate and right move in the same tick – ensure the game can handle it (maybe one will be processed this tick, one next tick). If using an input buffer or reading all keys each frame, ensure that combined actions don’t break anything (for example, rotating a piece at the exact moment it contacts ground – does lock delay still apply? It should).
- Testability Considerations: To facilitate the above tests, structure your code with separation of concerns:
- Model: core data structures like the grid, the piece (with its shape and position), score, etc. Provide methods to manipulate these (move, rotate, clear lines, etc.) that contain the logic, separate from input or output.
- Controller/Gameplay: the game loop that ties input, model, and output together. This part is harder to unit test, but if model is solid, this can be minimal.
- For unit testing, you can instantiate a Game state and call model methods directly, bypassing the real-time loop and actual keypresses. This makes tests deterministic and fast.
- Automated Testing: Use Go’s testing framework (
go test) to automate running your tests. Aim for high coverage on the critical logic. Testing the actual rendering on the console is not necessary (and not feasible in an automated way), but you can test that the strings or grid representations you generate are correct. For example, a function that returns a string of the playfield (for drawing) could be tested to ensure it correctly represents a given grid state.
- Building: Ensure the project is
go build-able on all major platforms. Include ago.modfile for dependency management so that runninggo buildorgo installwill fetch any needed libraries automatically. The output should be a single binary (e.g.,tetris.exeon Windows or justtetrison Unix). There should be no additional files required to run (aside from config/high score which will be generated or optional).- Running: Document in the README how to run the game. Typically:
- After building, the user can open a terminal and run
./tetristo start the game.- If there are command-line flags (for example,
--srsto use SRS rotation,--debugfor debug mode, etc.), list them and perhaps provide a-h/--helpoutput in the program to show usage.- Mention any expectations (like “runs in a 80x24 terminal; if your terminal is smaller, the playfield might not fit on screen”). For text mode, 80x24 is standard, and our playfield plus some info should fit, but if you added a lot of UI might need slightly bigger.
- Terminal Compatibility: The game should run in a standard console/terminal. It’s good to test on a few: Windows Command Prompt or PowerShell, a Linux terminal (xterm/GNOME Terminal), etc., especially if using escape codes or special libraries. Libraries like termbox or tcell handle a lot of this for you. If using termbox, note that it doesn’t work in certain environments (like the GoLand IDE console, as noted in some references) – but that’s okay as long as it works in a normal terminal. Document any such limitations.
- Installation: If publishing open-source, users should be able to install via
go install(e.g.,go install github.com/yourname/tetris@latest). This will fetch the source and build it. Provide the module path and usage in documentation. Alternatively, provide pre-built binaries for convenience.- Minimal Dependencies: Since this is a minimal console game, the only external dependency might be the terminal handling library. Make sure to list this in your documentation (e.g., “Uses termbox-go for cross-platform console graphics”). No need for heavy GUI or graphics libraries. This keeps the binary size small and avoids complex licensing issues.
- Cross-Compilation: With Go, it’s easy to cross-compile. Consider testing a build for Windows, Mac, Linux (if you’re not developing on all of those) to ensure nothing OS-specific is broken. Terminal handling libraries usually support all, but for example, termbox might not support older Windows terminals well – just be aware. If any special steps are needed for Windows (like enabling ANSI mode), mention them.
- Distribution: The game being a single binary means it can be distributed by simply providing that binary. In your repository or deliverable, include instructions on where to download or how to build. Also, include the source code (since this is a developer specification, the assumption is the developer will have the source).
- Post-Deployment: Provide user documentation (even if brief) – possibly as a README or as in-game instructions. For example, when the game launches, you could show a title screen with controls summary and how to start (or just start immediately and let the player figure out, but at least document in README). This ensures that once deployed, players (or testers) know how to actually use the product. By following this specification, a Go developer should be able to implement a classic Tetris game that runs in the console with smooth gameplay, accurate Tetris mechanics, and a robust structure. The focus is on clarity, maintainability, and an authentic Tetris feel, while also providing enough flexibility (via configuration and debug modes) to extend or tweak the game in the future.
The provided code appears to implement a console-based Tetris game in Go, fulfilling several specifications outlined for a classic Tetris game. The project includes key features such as grid management, tetromino creation and movement, scoring, line clearing, and game loop handling, all tailored for a text-based environment utilizing the
tcelllibrary for terminal control.Here's a concise breakdown of how the implementation meets the requirements:
- Playfield Dimensions: The grid is initialized as a 10x20 field, managed by the
Gridstruct ingrid.go.- Tetromino Set: Seven classic tetromino pieces are covered, each having distinct shapes and rotations defined in
tetromino.go.- Game Loop: The
Runfunction ingame.gohandles the core game loop with a ticker for consistent updates.
- Controls such as left/right movement, rotation, and soft/hard drop are mapped and handled in
game.goviaListenForInput. Special keys like Space for hard drop are implemented, but not explicitly for score boosts.
- The current code doesn't seem to explicitly implement selection between Simple Rotation and SRS. Default behavior resembles simple rotation. Wall kicks are possible but limited.
- Boundary Checks: These are implemented in
grid.goto prevent tetromino movement out of bounds.- Lock Delay: Lock timing and behavior are managed, though details are more basic than specified.
- Scores are managed in
game.gousing standard scoring. However, advanced features like combo bonuses, detailed soft/hard drop points, and configurable levels aren't extensively covered.- Level progression is managed by clearing lines, with level increasing per 10 cleared lines.
- Top-out condition is addressed by detecting if a new piece can spawn without collision.
- Extensive testing is provided with multiple test cases, verifying logic for game movements, grid states, and game conditions.
- High score saving, configuration files, and rotation system selection are not fully detailed within the provided code.
- Game state is rendered textually with helpful status info concerning score and level.
- Error handling for terminal inputs and game state consistency could be enhanced.
- The project shows readiness for unit tests but more functional coverage is advisable for completeness.
The game code fulfills many of the basic elements required to make a classic Tetris game in a console environment. Modifications would be needed to fully handle rotation systems, persistence, and configurability as detailed in the specification. For further implementation, focusing on adding SRS rotation, config file management, and advanced features like back-to-back bonuses or T-spin recognition would enhance game robustness and completeness according to the specification.
Check the code and implement the following:
- Allow remapping keys.
- Store high scores in a file (
highscore.json).- Allow toggling between Simple Rotation and SRS.
- Write tests:
- High scores should persist between sessions.
- Changing settings should affect gameplay correctly.
To implement configurable settings like key remapping, high score persistence, and toggling between Simple Rotation and SRS, follow these steps:
Key Remapping
- Create a configuration structure to store default key bindings.
- Add methods to adjust these settings from a configuration file.
High Score Persistence
- Implement functionality to write/read high scores to a
highscore.jsonfile using Go'sencoding/jsonpackage.Rotation System Toggle
- Implement a flag/config setting to switch between Simple Rotation and SRS within the game logic.
Here is how you can extend the existing code:
Create a new file
config.goto handle configuration settings:// file: config.go package main import ( "encoding/json" "io/ioutil" "log" "os" ) type KeyBindings struct { Left string Right string Down string Rotate string HardDrop string Pause string } type Config struct { KeyBindings KeyBindings SimpleRotation bool HighScore int } func NewConfig() *Config { return &Config{ KeyBindings: KeyBindings{ Left: "Left", Right: "Right", Down: "Down", Rotate: "Up", HardDrop: "Space", Pause: "P", }, SimpleRotation: true, // Default is simple rotation HighScore: 0, } } func LoadConfig(filename string) (*Config, error) { file, err := os.Open(filename) if err != nil { return nil, err } defer file.Close() config := NewConfig() err = json.NewDecoder(file).Decode(config) if err != nil { return nil, err } return config, nil } func (c *Config) SaveConfig(filename string) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() data, err := json.MarshalIndent(c, "", " ") if err != nil { return err } _, err = file.Write(data) return err }Modify
main.goto load configurations and adjust game settings based on them:// file: main.go package main import ( "log" "sync" "github.com/gdamore/tcell" ) func main() { config, err := LoadConfig("config.json") if err != nil { log.Println("Could not load config, using defaults:", err) config = NewConfig() } screen, err := tcell.NewScreen() if err != nil { panic(err) } if err := screen.Init(); err != nil { panic(err) } defer screen.Fini() var wg sync.WaitGroup wg.Add(2) game := NewGameWithScreen(screen) game.config = config // Pass the config to the game // Check rotation system preference from config game.useSimpleRotation = config.SimpleRotation go game.Run(&wg) go game.ListenForInput(&wg) wg.Wait() if err := config.SaveConfig("config.json"); err != nil { log.Println("Error saving config:", err) } }Add functions to save and load high scores in
game.go:// Add these functions in game.go func (g *Game) LoadHighScore() { file, err := os.Open("highscore.json") if err != nil { log.Println("Could not load high score:", err) return } defer file.Close() var score int err = json.NewDecoder(file).Decode(&score) if err != nil { log.Println("Error reading high score:", err) return } g.highScore = score } func (g *Game) SaveHighScore() { if g.score > g.highScore { file, err := os.Create("highscore.json") if err != nil { log.Println("Error saving high score:", err) return } defer file.Close() err = json.NewEncoder(file).Encode(g.score) if err != nil { log.Println("Error writing high score:", err) } } }Modify input handling routines to use remapped keys (in
game.go):// In ListenForInput add the config controls to match key bindings case tcell.KeyRune: key := string(e.Rune()) if key == config.KeyBindings.Left { g.grid.MoveTetromino(g.tetromino, "left") } else if key == config.KeyBindings.Right { g.grid.MoveTetromino(g.tetromino, "right") } else if key == config.KeyBindings.Down { g.softDrop = true } else if key == config.KeyBindings.Rotate { g.RotateTetromino() } else if key == config.KeyBindings.HardDrop { g.hardDrop() } else if key == config.KeyBindings.Pause { // Toggle between pause and game if g.status == "in game" { g.status = "paused" } else if g.status == "paused" { g.status = "in game" } g.Render() }Implement tests to ensure high score persistence and configuration effects:
// Add these functions in the grid_test.go or a new config_test.go file func TestConfigLoadSave(t *testing.T) { config := NewConfig() configFile := "test_config.json" err := config.SaveConfig(configFile) if err != nil { t.Fatalf("Failed to save config: %v", err) } loadedConfig, err := LoadConfig(configFile) if err != nil { t.Fatalf("Failed to load config: %v", err) } if config.KeyBindings != loadedConfig.KeyBindings { t.Errorf("Expected key bindings %v, but got %v", config.KeyBindings, loadedConfig.KeyBindings) } if config.SimpleRotation != loadedConfig.SimpleRotation { t.Errorf("Expected SimpleRotation %v, but got %v", config.SimpleRotation, loadedConfig.SimpleRotation) } } func TestHighScorePersistence(t *testing.T) { game := NewGame() game.score = 500 game.SaveHighScore() game.score = 0 game.LoadHighScore() if game.highScore != 500 { t.Errorf("Expected high score 500, but got %d", game.highScore) } // Clean up os.Remove("highscore.json") }This setup provides you with key remapping, high score storage, and the ability to switch between rotation systems with saved configurations that persist between sessions. Additionally, basic tests ensure that the configuration and high score functionalities work as intended.
g.highScore = score g.highScore???
It seems there is a missing
highScoreattribute in theGamestruct that tracks the highest score in the current session. Let's address this by updating theGamestruct to include thehighScorefield. Here is how you can integrate this properly with high score saving and loading:Update the
Gamestruct definition in yourgame.gofile to include thehighScorefield:// file: game.go type Game struct { screen tcell.Screen grid *Grid tetromino *Tetromino quit chan struct{} gameOver bool softDrop bool score int highScore int // Add highScore field linesCleared int level int message string messageTimer *time.Timer rng *rand.Rand status string config *Config // Reference to the configuration useSimpleRotation bool }Ensure the
highScoreis initialized when creating a new game instance, and load the high score:func NewGame() *Game { game := &Game{ grid: NewGrid(), quit: make(chan struct{}), rng: rand.New(rand.NewSource(time.Now().UnixNano())), score: 0, highScore: 0, // Initialize highScore linesCleared: 0, level: 1, status: "in game", } game.LoadHighScore() // Load the high score when a game is initialized return game }Ensure that you update the high scoring logic to store and retrieve the high score correctly:
// file: game.go func (g *Game) LoadHighScore() { file, err := os.Open("highscore.json") if err != nil { log.Println("Could not load high score:", err) return } defer file.Close() var score int err = json.NewDecoder(file).Decode(&score) if err != nil { log.Println("Error reading high score:", err) return } g.highScore = score } func (g *Game) SaveHighScore() { if g.score > g.highScore { g.highScore = g.score // Update high score for the current session file, err := os.Create("highscore.json") if err != nil { log.Println("Error saving high score:", err) return } defer file.Close() err = json.NewEncoder(file).Encode(g.highScore) if err != nil { log.Println("Error writing high score:", err) } } }Call
SaveHighScore()when the game ends:func (g *Game) stopGame() { g.SaveHighScore() // Additional game-over logic }Ensure that your high score tests verify that the value is saved and loaded correctly:
func TestHighScorePersistence(t *testing.T) { game := NewGame() game.score = 800 // Set a test score game.SaveHighScore() game.score = 0 // Reset score game.LoadHighScore() if game.highScore != 800 { t.Errorf("Expected high score 800, but got %d", game.highScore) } // Clean up the test artifact os.Remove("highscore.json") }By adding the
highScorefield to theGamestruct and managing it correctly, you ensure that the game can persist the highest score between sessions. This flow involves saving the high score when the game ends and loading it at the game's start, providing players with a consistent scoring experience.
./main.go:31:7: game.config undefined (type *Game has no field or method config)
The error indicates that the
configfield is not present in theGamestruct. To resolve this, you need to add aconfigfield to theGamestruct so that it can store and utilize the configuration settings, including key bindings and rotation preferences.
Add
configField to Game StructModify the
Gamestruct ingame.goto include aconfigfield.// file: game.go type Game struct { screen tcell.Screen grid *Grid tetromino *Tetromino quit chan struct{} gameOver bool softDrop bool score int highScore int linesCleared int level int message string messageTimer *time.Timer rng *rand.Rand status string config *Config // Add config field to Game struct useSimpleRotation bool }
Assign Configuration in NewGame Function
Ensure that the
NewGamefunction can receive aConfigand assign it to theconfigfield:func NewGameWithConfig(screen tcell.Screen, config *Config) *Game { rng := rand.New(rand.NewSource(time.Now().UnixNano())) game := &Game{ screen: screen, grid: NewGrid(), quit: make(chan struct{}), rng: rng, config: config, // Assign the provided config score: 0, highScore: 0, linesCleared: 0, level: 1, status: "in game", useSimpleRotation: config.SimpleRotation, } game.LoadHighScore() // Load the high score when a game is created return game }
Modify main.go to Use NewGameWithConfig
Update
main.goto properly useNewGameWithConfigwhen starting a game:// file: main.go package main import ( "log" "sync" "github.com/gdamore/tcell" ) func main() { config, err := LoadConfig("config.json") if err != nil { log.Println("Could not load config, using defaults:", err) config = NewConfig() } screen, err := tcell.NewScreen() if err != nil { panic(err) } if err := screen.Init(); err != nil { panic(err) } defer screen.Fini() var wg sync.WaitGroup wg.Add(2) game := NewGameWithConfig(screen, config) // Use the new function with config go game.Run(&wg) go game.ListenForInput(&wg) wg.Wait() if err := config.SaveConfig("config.json"); err != nil { log.Println("Error saving config:", err) } }By incorporating these changes, you ensure the
Gamestruct has access to aConfigobject, enabling key remapping and rotation system toggling. TheNewGameWithConfigconstructor seamlessly initializes the game with the loaded configurations, integrating them into the main game loop and input management.
- Build standalone binaries (
go build).- Provide a README with usage instructions.
- Distribute as a single binary (
tetris.exe,tetris).
To effectively package and release your Tetris game as a standalone binary, you should follow these steps:
You need to compile your Go application into standalone binaries. Go's cross-compilation capabilities allow you to build for multiple platforms easily.
Run the following commands in your terminal to build binaries for each target platform:
# For Linux GOOS=linux GOARCH=amd64 go build -o tetris # For Windows GOOS=windows GOARCH=amd64 go build -o tetris.exe # For macOS GOOS=darwin GOARCH=amd64 go build -o tetrisThese commands will produce executables named
tetris.exefor Windows andtetrisfor Linux and macOS. AdjustGOARCHif you're targeting additional architectures like ARM, etc.Create a
README.mdfile with essential usage instructions, detailing how to run the game and any configurations or dependencies. Here is a template you can start with:# Tetris Console Game This is a classic Tetris game developed in Go, designed to run in a console environment. ## Installation ### Build from Source To build the binary from source, ensure you have Go installed, then run: ```shell go buildThis will produce a
tetrisortetris.exebinary in your current directory.Alternatively, you can download the pre-built binaries from the releases section:
- Windows:
tetris.exe- Linux:
tetris- macOS:
tetrisRun the game by executing the binary:
tetris.exe./tetris
- Move Left: Left Arrow
- Move Right: Right Arrow
- Soft Drop: Down Arrow
- Rotate Clockwise: Up Arrow
- Hard Drop: Spacebar
- Pause: P
The game supports configuration through a JSON file
config.json:
- Key remapping: Adjust the keys for game controls.
- Rotation System Toggle: Choose between Simple Rotation and SRS.
Upon the first run, a default
config.jsonfile will be generated if one doesn't exist.High scores are stored in
highscore.jsonand persist across game sessions.This project is licensed under the MIT License.
### Step 3: Distribute as a Single Binary To distribute your game as a single binary, you simply need to provide the executable file. Ensure you include: - **README.md**: Explain how to run the binary and settings to configure. - **Binaries**: Provide the standalone binaries directly for various platforms. Upload these files to a distribution platform or repository like GitHub Releases where users can download the pre-built binaries for the desired platform. ### Conclusion By following these steps, you'll have a well-packaged Tetris game ready for distribution. Always test the compiled binaries on their respective platforms to ensure proper functionality. This approach ensures your game is easy to install, run, and configure, offering an excellent user experience from download to gameplay.