This is part of the article Unified Gaming: Tic-Tac-Toe on Android with Spring Boot
The code is available on Github at the link: Tic-tac-toe-server-side
Application architecture
Within this architecture there are four main components: registration, statistics, lobby and game.
Registration
Registration is responsible for adding new users to the system. The user enters his name, which is saved in the database. If the name already exists, an error message is displayed to the user. After successful registration, the user receives an unique identifier, which is used to identify him in further interaction with the server.
Statistics
Statistics display information about the number of games played and the number of wins for each player. The server accesses the database to obtain the requested information and sends it to the user.
Lobby
The lobby is where players can wait to connect to the game. A user who enters the lobby becomes available for play to other players who are also in the lobby. When two players are in the lobby at the same time, the server creates a new game and sends them an invitation to start.
Game
After two players have accepted the invitation and started the game, they take turns filling in the cells on the playing field. The server checks each move and determines whether the game ends with a victory for one of the players or a draw. If the game ends, the server updates the players’ statistics and gives them the opportunity to start a new game.
Development
In this section, we will take a detailed look at the process of creating a Spring Boot server for our project, including setting up WebSocket, working with a MySQL database, and other key aspects.
Setting up WebSocket
To ensure real-time data exchange between client and server, we use WebSocket technology. First, we need to configure WebSocket in our application.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private static final String PLAYER_DESTINATION = "/player";
private static final String APP_DESTINATION = "/app";
private static final String STOMP_ENDPOINT = "/websocket";
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker( PLAYER_DESTINATION);
config.setApplicationDestinationPrefixes(APP_DESTINATION);
config.setUserDestinationPrefix(PLAYER_DESTINATION);
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint(STOMP_ENDPOINT);
}
}
In this class we define the message broker configuration and register the access point for the STOMP protocol.
Working with a MySQL database
We use a MySQL database to save game data and user information. To do this, we configure the appropriate dependencies and configuration.
spring.jpa.hibernate.ddl-auto=update spring.datasource.url=jdbc:mysql://127.0.0.1:3306/tic_tac_toe spring.datasource.username=admin spring.datasource.password=password spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver #spring.jpa.show-sql: true
Use the following command to create a MySQL database:
create database tic_tac_toe;
Interaction with the database is carried out using Spring Data JPA, which greatly simplifies working with entities and CRUD (Create, Read, Update, Delete) operations.
For each entity (game, lobby, player), we created separate repository interfaces that inherit from CrudRepository. This interface provides basic methods for working with entities, such as saving, deleting, and searching.
public interface GameRepository extends CrudRepository<Game, Integer> {
}
public interface LobbyRepository extends CrudRepository<Lobby, Integer> {
List<Lobby> findAll();
Lobby findFirstByOrderByIdAsc();
}
public interface PlayerRepository extends CrudRepository<Player, Integer> {
boolean existsByName(String name);
}
Description of entities
Each entity is represented in the database as a table. Below is a description of the entities and their relationships:
- Game: Tracks the state of the current game, stores information about the playing field, the current player and the other player. Connected with players in an one-to-many relationship (OneToMany).
- Lobby: Stores information about the user who is in the lobby waiting for the game to start. Each player can only be in one lobby. Provides the ability to quickly search for players to start a new game.
- Player: Represents the user of the application. Stores information about the username, number of games played and victories. Related to a game by a many-to-one relationship (ManyToOne), where one game can have many players.
Entities are automatically mapped to tables in the database using JPA annotations. An example description of the essence of the game is given below:
@Entity
public class Lobby {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private Integer playerId;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Integer getPlayerId() {
return playerId;
}
public void setPlayerId(Integer playerId) {
this.playerId = playerId;
}
}
Request classes for controllers and responses for the client side
To enable communication between the client side and the server in the Tic Tac Toe application, various request classes are used, which are passed as a request body (@RequestBody) to the corresponding server-side controllers. These classes are presented below:
public class BaseRequest {
private int playerId;
public BaseRequest() {
}
public BaseRequest(int playerId) {
this.playerId = playerId;
}
public int getPlayerId() {
return playerId;
}
}
This class is used as a base class for other queries. It contains only the playerId, which identifies the player making the request.
GameRequest
This class is used for gameplay related requests. It contains the player ID and the cell index that the player clicked on in the playing field.
public class GameRequest {
private int playerId;
private int cellIndex;
}
RegistrationRequest
This class is used to register new players. It contains the Android ID and the name of the new player.
public class RegistrationRequest {
private String androidId;
private String name;
}
LobbyResponse
This class is used to respond from the server to requests related to the game lobby. It contains information about the lobby status, player names and PreGameResponse.
public class LobbyResponse {
public enum Status {
WAITING, FOUND
}
private Status status;
private String playerName;
private String enemyName;
private PreGameResponse preGameResponse;
}
PreGameResponse
This class is used for preliminary information about the game. So that before the start of the game, players can receive information about whose turn it is and whether they can move.
public class PreGameResponse {
private String currentPlayer;
private boolean canMove;
}
RegistrationResponse
This class is used to respond to new player registration requests. It contains information about the registration operation status and player ID.
public class RegistrationResponse {
public enum Status{
SUCCESS, ALREADY_EXISTS
}
public static final String SUCCESS_MSG = "Success";
public static final String ALREADY_EXISTS_MSG = "Player already exists";
private final Status status;
private final String message;
private int playerId;
}
StatisticsResponse
This class is used to provide player statistics. It contains information about the player’s name, number of games and wins.
public class StatisticsResponse {
private String name;
private int numberOfGames;
private int numberOfWins;
}
These classes enable the transfer of various types of data between the client side and the server in the Tic-Tac-Toe game, allowing players to interact with the game and other players over the network.
Controllers for controlling gameplay
To process requests related to the gameplay, as well as to manage the lobby, player registration and statistics, appropriate controllers are used. Let’s look at each of them:
GameController
This controller handles gameplay related requests. It takes an object of type GameRequest as the body of the request and processes the player’s actions, changing the game state and sending game field updates to other players.
@Controller
public class GameController {
private static final String GAME_DESTINATION = "/queue/game";
private static final String GAME_MAPPING = "/game";
@Autowired
private SimpMessagingTemplate msgTemplate;
@Autowired
private PlayerRepository playerRepository;
@Autowired
private GameRepository gameRepository;
@MessageMapping(GAME_MAPPING)
public void game(@RequestBody GameRequest request){
var player = playerRepository.findById(request.getPlayerId()).get();
var game = player.getGame();
var symbol = player.getSymbol();
if (game.updateBoardState(player, request.getCellIndex())){
game.changeCurrentPlayer();
game.toggleCanMove();
gameRepository.save(game);
if (game.isGameOver(player)){
var winner = game.getWinner(player);
var player1 = game.getPlayers().get(0);
var player2 = game.getPlayers().get(1);
player1.reset();
player2.reset();
game.increaseNumberOfGames(winner, player1, player2);
playerRepository.saveAll(game.getPlayers());
gameRepository.delete(game);
var gameResponse = new GameResponse(GameResponse.Status.FINISHED, request.getCellIndex(), symbol, game.getWinnerMsg(winner));
msgTemplate.convertAndSendToUser(
player1.getId().toString(),
GAME_DESTINATION,
gameResponse);
msgTemplate.convertAndSendToUser(
player2.getId().toString(),
GAME_DESTINATION,
gameResponse);
}else {
var player1 = game.getPlayers().get(0);
var player2 = game.getPlayers().get(1);
msgTemplate.convertAndSendToUser(
player1.getId().toString(),
GAME_DESTINATION,
new GameResponse(GameResponse.Status.IN_PROGRESS, request.getCellIndex(), symbol, game.getCurrentPlayer(), player1.getCanMove()));
msgTemplate.convertAndSendToUser(
player2.getId().toString(),
GAME_DESTINATION,
new GameResponse(GameResponse.Status.IN_PROGRESS, request.getCellIndex(), symbol, game.getCurrentPlayer(), player2.getCanMove()));
}
playerRepository.saveAll(game.getPlayers());
}
}
}
LobbyController
This controller controls the game waiting process in the lobby. It accepts an object of type BaseRequest and processes requests from players in the lobby, determining when they are ready to start a game, and creating a new game when enough players are found.
@Controller
public class LobbyController {
private static final String LOBBY_DESTINATION = "/queue/lobby";
private static final String LOBBY_MAPPING = "/lobby";
private static final int EMPTY_LOBBY_SIZE = 0;
@Autowired
private SimpMessagingTemplate msgTemplate;
@Autowired
private LobbyRepository lobbyRepository;
@Autowired
private PlayerRepository playerRepository;
@Autowired
private GameRepository gameRepository;
@MessageMapping(LOBBY_MAPPING)
public void lobby(@RequestBody BaseRequest request){
var player = playerRepository.findById(request.getPlayerId()).get();
var playersInLobby = lobbyRepository.findAll().size();
if (playersInLobby == EMPTY_LOBBY_SIZE){
Lobby lobby = new Lobby();
lobby.setPlayerId(request.getPlayerId());
lobbyRepository.save(lobby);
msgTemplate.convertAndSendToUser(
String.valueOf(request.getPlayerId()),
LOBBY_DESTINATION,
new LobbyResponse(LobbyResponse.Status.WAITING));
}else {
Lobby lobby = lobbyRepository.findFirstByOrderByIdAsc();
Player enemy = playerRepository.findById(lobby.getPlayerId()).get();
if (!enemy.getName().equals(player.getName())){
lobbyRepository.delete(lobby);
Game game = new Game();
game.setPlayers(Arrays.asList(player, enemy));
game.init();
player.setGame(game);
enemy.setGame(game);
gameRepository.save(game);
playerRepository.save(player);
playerRepository.save(enemy);
msgTemplate.convertAndSendToUser(
String.valueOf(request.getPlayerId()),
LOBBY_DESTINATION,
new LobbyResponse(LobbyResponse.Status.FOUND, player.getName(), enemy.getName(),
new PreGameResponse(game.getCurrentPlayer(), player.getCanMove())));
msgTemplate.convertAndSendToUser(
enemy.getId().toString(),
LOBBY_DESTINATION,
new LobbyResponse(LobbyResponse.Status.FOUND, enemy.getName(), player.getName(),
new PreGameResponse(game.getCurrentPlayer(), enemy.getCanMove())));
}else {
msgTemplate.convertAndSendToUser(
String.valueOf(request.getPlayerId()),
LOBBY_DESTINATION,
new LobbyResponse(LobbyResponse.Status.WAITING));
}
}
}
}
RegistrationController
This controller is responsible for registering new players. It accepts an object of type RegistrationRequest, creates a new player in the database and sends a response to the client about the registration status.
@Controller
public class RegistrationController {
private static final String REGISTRATION_DESTINATION = "/queue/registration";
private static final String REGISTRATION_MAPPING = "/registration";
@Autowired
private SimpMessagingTemplate msgTemplate;
@Autowired
private PlayerRepository playerRepository;
@MessageMapping(REGISTRATION_MAPPING)
public void registration(@RequestBody RegistrationRequest request){
var alreadyExists = playerRepository.existsByName(request.getName());
if (alreadyExists){
msgTemplate.convertAndSendToUser(
request.getAndroidId(),
REGISTRATION_DESTINATION,
new RegistrationResponse(RegistrationResponse.Status.ALREADY_EXISTS, RegistrationResponse.ALREADY_EXISTS_MSG));
}else {
Player player = new Player();
player.setName(request.getName());
playerRepository.save(player);
msgTemplate.convertAndSendToUser(
request.getAndroidId(),
REGISTRATION_DESTINATION,
new RegistrationResponse(
RegistrationResponse.Status.SUCCESS,
RegistrationResponse.SUCCESS_MSG,
player.getId()));
}
}
}
StatisticsController
This controller processes requests to obtain player statistics. It accepts an object of type BaseRequest, retrieves the player data from the database and sends the player statistics back to the client.
@Controller
public class StatisticsController {
private static final String STATISTICS_DESTINATION = "/queue/statistics";
private static final String STATISTICS_MAPPING = "/statistics";
@Autowired
private SimpMessagingTemplate msgTemplate;
@Autowired
private PlayerRepository playerRepository;
@MessageMapping(STATISTICS_MAPPING)
public void statistics(@RequestBody BaseRequest request){
var player = playerRepository.findById(request.getPlayerId()).get();
msgTemplate.convertAndSendToUser(
String.valueOf(request.getPlayerId()),
STATISTICS_DESTINATION,
new StatisticsResponse(player.getName(), player.getNumberOfGames(), player.getNumberOfWins()));
}
}
These controllers provide client-side and server-side interaction in the Tic-Tac-Toe game, allowing for various types of requests to be handled and gameplay controlled.
Conclusion
In conclusion of the development of the server part on Spring Boot, we can note the key points and achievements of the project.
We initially chose Spring Boot to build the backend of the application due to its flexibility, ease of use, and large support community. The project structure was organized taking into account the principles of clean architecture, which contributed to ease of support and expansion of functionality in the future.
Some of the work focused on setting up WebSocket to enable two-way communication between clients and server. This allowed for real-time gameplay, which is a key feature of the application.
We also used a MySQL database to store user information and game statistics, ensuring reliable and efficient data storage.