I am a beginner at programming. I made this snake game in JavaFX to practice and improve my coding skills. I tried to make my code clean, but I'm not sure if it is, or if it is well-organized. I also tried to make the design object oriented. So I would like to ask your opinion on my code's structure and clarity. I would appreciate any advice you give me to improve.
This is what the game looks like
I divided the program into four classes:
SnakeGame is the main class that is responsible for putting the game together and starting, restarting, ending the game, and taking user input.
GameBoard extends Pane. It is responsible for the snake and the fruit objects. It has an inner class, SnakeMovement, that extends TimerTask. This task invokes the methods for checking for collisions, checking if the snake ate a fruit, and invokes the moveSnake() method in the Snake class.
Snake class extends ArrayList. It has the methods for creating the snake, moving the snake, and adding a new head to the snake when a fruit is eaten.
Fruit class extends Rectangle. It has the methods to get a random empty tile and to spawn the fruit in an empty tile.
SnakeGame.java
import java.util.Timer;
import java.util.TimerTask;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.stage.Stage;
public class SnakeGame extends Application {
private BorderPane root;
private HBox scoreBar;
private Label scoreIndicator;
private int score;
private Button replay;
private GameBoard GAME_BOARD;
private final int TILE_SIZE = 20;
private int keyX;
private int keyY;
private Timer timer;
private TimerTask task;
@Override
public void start(Stage stage) {
root = new BorderPane();
root.setId("root");
scoreBar = new HBox();
scoreBar.setId("scoreBar");
root.setTop(scoreBar);
scoreIndicator = new Label("Score: 0");
scoreIndicator.setId("scoreIndicator");
replay = new Button("Replay");
replay.setId("replay");
replay.setOnAction(e -> restartGame());
Region filler = new Region();
HBox.setHgrow(filler, Priority.ALWAYS);
scoreBar.getChildren().addAll(scoreIndicator, filler, replay);
GAME_BOARD = new GameBoard(this, TILE_SIZE, 20, 20);
GAME_BOARD.setOnKeyPressed(e -> takeInput(e));
root.setCenter(GAME_BOARD);
Scene scene = new Scene(root);
String css = this.getClass().getResource("snake_game.css").toExternalForm();
scene.getStylesheets().add(css);
stage.setScene(scene);
stage.setTitle("Snake Game");
stage.setResizable(false);
stage.setOnCloseRequest(e -> {
System.exit(0);
});
stage.show();
startGame();
}
public void startGame() {
GAME_BOARD.requestFocus();
replay.setVisible(false);
timer = new Timer();
task = GAME_BOARD.new SnakeMovement();
timer.scheduleAtFixedRate(task, 100, 100);
}
public void endGame() {
timer.cancel();
replay.setVisible(true);
}
public void restartGame() {
resetKeys();
resetScore();
GAME_BOARD.resetGameBoard();
startGame();
}
public void resetKeys() {
keyX = 0;
keyY = 0;
}
public void resetScore() {
score = 0;
scoreIndicator.setText("Score: 0");
}
public void increaseScore() {
score++;
scoreIndicator.setText("Score: " + score);
}
public void takeInput(KeyEvent e) {
switch (e.getCode()) {
case DOWN -> {
keyX = 0; keyY = TILE_SIZE;
}
case UP -> {
keyX = 0; keyY = -TILE_SIZE;
}
case RIGHT -> {
keyX = TILE_SIZE; keyY = 0;
}
case LEFT -> {
keyX = -TILE_SIZE; keyY = 0;
}
}
}
public int getKeyX() {
return keyX;
}
public int getKeyY() {
return keyY;
}
public static void main(String[] args) {
launch(args);
}
}
GameBoard.java
import java.util.ArrayList;
import java.util.TimerTask;
import javafx.application.Platform;
import javafx.geometry.Point2D;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
public class GameBoard extends Pane {
private final SnakeGame GAME;
private final int TILE_SIZE;
private final int TILES_IN_ROW;
private final int TILES_IN_COLUMN;
private final int ROW_SIZE;
private final int COLUMN_SIZE;
private final int TOTAL_TILES;
private ArrayList<Point2D> emptyTiles;
private final Snake SNAKE;
private final Fruit FRUIT;
private int dX;
private int dY;
public GameBoard(SnakeGame game, int tileSize, int tilesInRow, int tilesInColumn) {
GAME = game;
TILE_SIZE = tileSize;
TILES_IN_ROW = tilesInRow;
TILES_IN_COLUMN = tilesInColumn;
ROW_SIZE = TILE_SIZE * TILES_IN_ROW;
COLUMN_SIZE = TILE_SIZE * TILES_IN_COLUMN;
TOTAL_TILES = TILES_IN_ROW * TILES_IN_COLUMN;
setPrefSize(ROW_SIZE, COLUMN_SIZE);
SNAKE = new Snake(this, TILE_SIZE, 3, Color.GREENYELLOW);
FRUIT = new Fruit(TILE_SIZE, Color.RED);
getChildren().add(FRUIT);
setGameBoard();
}
public void setGameBoard() {
dY = TILE_SIZE;
emptyTiles = createEmptyTilesList();
SNAKE.setEmptyTiles(emptyTiles);
FRUIT.setEmptyTiles(emptyTiles);
SNAKE.generateSnake();
FRUIT.spawnFruit();
}
public void resetGameBoard() {
dX = 0;
dY = 0;
getChildren().removeAll(SNAKE);
SNAKE.clear();
setGameBoard();
}
public ArrayList<Point2D> createEmptyTilesList() {
ArrayList<Point2D> emptyTiles = new ArrayList<>();
for (int tile = 1, x = 0, y = 0; tile <= TOTAL_TILES; tile++) {
emptyTiles.add(new Point2D(x, y));
x += TILE_SIZE;
if (tile % TILES_IN_ROW == 0) {
x = 0;
y += TILE_SIZE;
}
}
return emptyTiles;
}
public boolean isDirectionValid() {
//the snake shouldn't move in the opposite direction
return GAME.getKeyX() + dX != 0 && GAME.getKeyY() + dY != 0;
}
public boolean ateFruit() {
Rectangle head = SNAKE.getSnakeHead();
return head.getX() == FRUIT.getX() && head.getY() == FRUIT.getY();
}
public boolean willHitWall() {
Rectangle head = SNAKE.getSnakeHead();
return head.getX() + dX < 0 || head.getX() + dX >= this.getWidth() ||
head.getY() + dY < 0 || head.getY() + dY >= this.getHeight();
}
public boolean willHitBody() {
Rectangle head = SNAKE.getSnakeHead();
for (Rectangle body : SNAKE) {
if (head.getX() + dX == body.getX() && head.getY() + dY == body.getY())
return true;
}
return false;
}
class SnakeMovement extends TimerTask {
@Override
public void run() {
Platform.runLater(() -> {
if (isDirectionValid()) {
dX = GAME.getKeyX();
dY = GAME.getKeyY();
}
if (ateFruit()) {
FRUIT.spawnFruit();
SNAKE.addHead();
GAME.increaseScore();
}
if (willHitWall() || willHitBody()) {
GAME.endGame();
return;
}
SNAKE.moveSnake(dX, dY);
});
}
}
}
Snake.java
import java.util.ArrayList;
import javafx.geometry.Point2D;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
public class Snake extends ArrayList<Rectangle> {
private final int SNAKE_BLOCK_SIZE;
private final int SNAKE_INITIAL_LENGTH;
private final Color SNAKE_COLOR;
private final GameBoard GAME_BOARD;
private ArrayList<Point2D> emptyTiles;
public Snake(GameBoard gameBoard, int snakeBlockSize, int snakeInitialLength, Color snakeColor) {
GAME_BOARD = gameBoard;
SNAKE_BLOCK_SIZE = snakeBlockSize;
SNAKE_INITIAL_LENGTH = snakeInitialLength;
SNAKE_COLOR = snakeColor;
}
public Rectangle createBodyPart() {
Rectangle body = new Rectangle(SNAKE_BLOCK_SIZE, SNAKE_BLOCK_SIZE, SNAKE_COLOR);
GAME_BOARD.getChildren().add(body);
return body;
}
public void generateSnake() {
for (int tile = SNAKE_INITIAL_LENGTH - 1; tile >= 0; tile--) {
Rectangle body = createBodyPart();
int startX = (int)GAME_BOARD.getPrefWidth() / 2;
//0 is the head and 2 is the tail, if snake's initial length is 3,
//startY will be 40, 20, and 0, for elements 0, 1, and 2 respectively
//since the snake is moving downward
int startY = tile * SNAKE_BLOCK_SIZE;
body.setX(startX);
body.setY(startY);
emptyTiles.remove(new Point2D(startX, startY));
add(body);
}
}
public void moveSnake(int dX, int dY) {
Rectangle head = getSnakeHead();
boolean isHeadAdded = head.getX() == get(1).getX() && head.getY() == get(1).getY();
double oldX = head.getX();
double oldY = head.getY();
double newX = oldX + dX;
double newY = oldY + dY;
head.setX(newX);
head.setY(newY);
//the head now occupies this tile
emptyTiles.remove(new Point2D(newX, newY));
//when a new head is added, the rest of the body stays still for one task
//so there is no need to go through the loop, and the tail's place shouldn't
//be added to emptyTiles
if (isHeadAdded) {
return;
}
for (int i = 1; i < size(); i++) {
Rectangle body = get(i);
double currentX = body.getX();
double currentY = body.getY();
body.setX(oldX);
body.setY(oldY);
oldX = currentX;
oldY = currentY;
}
//the old tile of the tail is now empty
emptyTiles.add(new Point2D(oldX, oldY));
}
public void addHead() {
Rectangle oldHead = getSnakeHead();
Rectangle newHead = createBodyPart();
//the new head is set on the same tile as the old head initially
//but it will be moved in the moveSnake method afterwards
newHead.setX(oldHead.getX());
newHead.setY(oldHead.getY());
setSnakeHead(newHead);
}
public Rectangle getSnakeHead() {
return get(0);
}
public void setSnakeHead(Rectangle newHead) {
add(0, newHead);
}
public void setEmptyTiles(ArrayList<Point2D> emptyTiles) {
this.emptyTiles = emptyTiles;
}
}
Fruit.java
import java.util.ArrayList;
import java.util.Random;
import javafx.geometry.Point2D;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
public class Fruit extends Rectangle {
private ArrayList<Point2D> emptyTiles;
public Fruit(int blockSize, Color color) {
super(blockSize, blockSize, color);
}
public Point2D generateRandomTile() {
Random random = new Random();
int randomIndex = random.nextInt(emptyTiles.size());
return emptyTiles.get(randomIndex);
}
public void spawnFruit() {
Point2D randomTile = generateRandomTile();
this.setX(randomTile.getX());
this.setY(randomTile.getY());
}
public void setEmptyTiles(ArrayList<Point2D> emptyTiles) {
this.emptyTiles = emptyTiles;
}
}

