The AngularPwaMessenger project has been extended to provide a multiplayer Bingo Game. This article will describe the implementation of the game in the Spring Boot backend and the Angular frontend. The multiplayer features will be in the next part.

Architecture

To make sure that the chances are equal the backend has to provide the bingo boards, the random numbers and the bingo win checks. The frontend provides the user experience.

The AngularPwaMessenger has the new standalone angular games module that is lazy loaded and provides the frontend for the bingo game. It uses services to load the bingo boards, the random numbers and to check for wins. The backend provides the bingo controller with the rest endpoints, the bingo service with the implementation logic and the bingo repository to store the documents in the MongoDB.

Spring Boot Bingo Backend

The MongoDb document is in the BingoGame:

@Document
public class BingoGame {
  public record BingoBoard(int[][] board, boolean[][] hits) {}
	
  @Id
  private ObjectId id;
  @JsonProperty
  private String uuid;
  @JsonProperty
  private LocalDateTime lastUpdate = LocalDateTime.now();
  @JsonProperty
  private List<BingoBoard> bingoBoards = new ArrayList<>();
  @JsonProperty
  private List<String> playerUserIds = new ArrayList<>();
  @JsonProperty
  private List<Integer> randomValues = new ArrayList<>();

This is the document stored with its annotations. The BingoBoard record stores to 5*5 Bingo number board and the 5*5 Bingo hit board. The ‘uuid’ property stores the UUID string of the game. The ‘lastUpdate’ property stores the time the randomvalues have been updated. The ‘bingoBoards’ are the BingoBoard records of all the players participating in the same order of the ‘playerUserIds’ with their ids. The ‘randomValues’ are stored to avoid duplicates when generating the random values and to check wins.

The BingController looks like this:

@RestController
@RequestMapping("/rest/games/bingo")
public class BingoController {	
  private final BingoService bingoService;
	
  public BingoController(BingoService bingoService) {
    this.bingoService = bingoService;
  }
	
  @PostMapping("/newgame")
  public Mono<BingoGame> postStartGame(@RequestBody BingoGame bingoGame) {
    return this.bingoService.startBingoGame(bingoGame);
  }
	
  @GetMapping("/updategame/{uuid}")
  public Mono<BingoGame> getUpdateGame(@PathVariable("uuid") String uuid) {
    return this.bingoService.updateBingoGame(uuid);
  }
	
  @GetMapping("/checkwin/{gameUuid}/user/{userUuid}")
  public Mono<Boolean> getCheckWin(@PathVariable("gameUuid") String gameUuid, 
    @PathVariable("userUuid") String userUuid) {
    return this.bingoService.checkWin(gameUuid, userUuid);
  }

  @GetMapping("/endgame/{gameUuid}")
  public Mono<Boolean> getEndGame(
    @PathVariable("gameUuid") String gameUuid) {
    return this.bingoService.endGame(gameUuid);
  }
}

The controller uses the BingoService.

The ‘@PostMapping’ annotation is used to create the ‘postStartGame(…)’ endpoint with the ‘@RequestBody’ annotation to map the request body to the BingoGame class and calls the BingoService.

The ‘@GetMapping’ annotation is used to create the ‘getUpdateGame(…)’ endpoint with the ‘@PathVariable’ to store the game Uuid in the variable and call the BingoService method.

The ‘@GetMapping’ annotation is used to create the ‘getCheckWin(…)’ endpoint with the ‘@PathVariable’ to store the game Uuid and the user Uuid and call the BingoService method.

The ‘@GetMapping’ annotation is used to create the ‘getEndGame(…)’ endpoint with the ‘@PathVariable’ to store the game Uuid and call the BingoService method.

The MessageCronJob starts the cron job to remove old games:

@Component
public class MessageCronJob {
  private static final Logger LOG = 
    LoggerFactory.getLogger(MessageCronJob.class);
  @Value("${cronjob.message.ttl.days}")
  private Long messageTtl;
  private final MessageService messageService;
  private final BingoService bingoService;
	
  public MessageCronJob(MessageService messageService, BingoService 
    bingoService) {
    this.messageService = messageService;
    this.bingoService = bingoService;
  }
	
  ...
	
  /**
   * remove last updated more than a day ago. 
   */
  @Scheduled(cron = "0 5 2 * * ?")
  @SchedulerLock(name = "BingoCleanUp_scheduledTask", lockAtLeastFor = 
    "PT2H", lockAtMostFor = "PT3H")
  public void cleanUpOldBingoGames() {
    this.bingoService.cleanUpGames();
  }
}

The ‘@Scheduled(cron = "0 5 2 * * ?")’ annotation starts the job at 2.05 o’clock. The ‘@SchedulerLock(…)’ creates a database lock to make sure only one instance is started if multiple instances are deployed. The lock lasts for at least 2 hours and at most 3 hours. The BingoService is called to perform the removal.

The BingService implements the logic:

@Service
public class BingoService {
  private static final Logger LOG = 
    LoggerFactory.getLogger(BingoService.class);

  private record BingoGameChanged(BingoGame bingoGame, 
    AtomicBoolean changed) {}

  private final MyMongoRepository repository;

  public BingoService(MyMongoRepository repository) {
    this.repository = repository;
  }

  public Mono<BingoGame> startBingoGame(BingoGame bingoGame) {
    bingoGame.setUuid(UUID.randomUUID().toString());
    bingoGame.setLastUpdate(LocalDateTime.now());
    bingoGame.getBingoBoards().addAll(bingoGame
     .getPlayerUserIds().stream().map(myUserId -> 
       new BingoGame.BingoBoard(new int[5][5], new boolean[5][5]))
       .map(myBingoBoard -> this.initBingoBoard(myBingoBoard)).toList());
    return this.repository.save(bingoGame);
  }

  public Mono<BingoGame> updateBingoGame(String uuid) {
    return this.repository.findOne(new Query().addCriteria(
      Criteria.where("uuid").is(uuid)), BingoGame.class)
	.map(myBingoGame -> this.updateBingoGame(
          new BingoGameChanged(myBingoGame, new AtomicBoolean(false))))
            .flatMap(myRecord -> myRecord.changed().get() ? 
              this.repository.save(myRecord.bingoGame()) : 
              Mono.just(myRecord.bingoGame()));
  }

  public Mono<Boolean> checkWin(String gameUuid, String userUuid) {
    return this.repository.findOne(new Query().addCriteria(
      Criteria.where("uuid").is(gameUuid)), BingoGame.class)
        .flatMap(myBingoGame -> this.checkBoard(myBingoGame, userUuid));
  }

  public Mono<Boolean> endGame(String gameUuid) {
    return this.repository.remove(new Query().addCriteria(
      Criteria.where("uuid").is(gameUuid)), BingoGame.class)
        .map(result -> result.wasAcknowledged());
  }

  public void cleanUpGames() {
    LOG.info("Cleanup bingo games started.");
    Date cutoffTimeStamp = Date.from(LocalDateTime.now().minusDays(1L)
      .atZone(ZoneId.systemDefault()).toInstant());		
    this.repository.findAllAndRemove(new Query().addCriteria(
      Criteria.where("lastUpdate").lt(cutoffTimeStamp)),   
        BingoGame.class).collectList().block();		
    LOG.info("Cleanup bingo games finished.");
  }
	  
  ... The private methods can be found in the Github repo.

}

The BingoService implements the ‘startBingoGame(…)’ method to update the BingoGame document received from the frontend to start a game. The game ‘uuid’ and the ‘lastUpdate’ is set. Then the bingo boards are initialized with random numbers and the hit board is set with false values in the order of the ‘playerUserIds’ list. Then the document is saved with the repository.

The method ‘updateBingoGame(…)’ queries the database with the game uuid for the ‘BingoGame’ document and the maps it in a ‘BingoGameChanged’ record to add the changed flag. Then it checks if the ‘lastUpdate’ LocalDateTime is old enough for a new random value. If true the random value is added to the ‘randomValues’ list and the hits board is updated to show the result of the new value for the players. If updates were made the new ‘BingoGame’ document is stored in the database with the repository. The private methods can be found in the Github Repo file.

The method ‘endGame(…)’ removes the ‘BingoGame’ document matching the uuid of the game. The result is mapped to the result boolean.

The method ‘checkWin(…)’ queries the database with game uuid and the user uuid for the ‘BingoGame’ document. It then checks if the user has won the bingo game and returns the boolean value. This is the definitive result of the game. The frontend checks the board and sends the request if it has found a win. The user sees the win after the frontend and the backend agree on the win.

The method ‘cleanUpGames()’ is called with the ‘@Scheduled’ annotation. It queries for all ‘BingoGame’ documents with ‘lastUpdate’ properties older than a Day and removes them to clean up the database.

Conclusion Backend

The AngularPwaMessenger project has a contact management that can be reused. The the Spring Reactor project provides a very good api to implement reactive rest endpoints with MongoDb repositories. The MongoDb document database fits the use case because there is very little redundancy in the documents.

Frontend

The bingo frontend is loaded in the as lazy loaded standalone components. The route is provided by the app-routing.module.ts:

const routes: Routes = [
  {
    path: "games",
    loadChildren: () => import("./games").then((mod) => mod.GAMES),
  },
  { path: "**", component: MainComponent },
];

@NgModule({
  imports: [RouterModule.forRoot(routes, {})],
  exports: [RouterModule],
})
export class AppRoutingModule {}

The ‘games’ route lazy loads the ‘GAMES’ routes in the ‘./games’ directory. That directory has a index.ts that exports the games-routes.ts:

export const GAMES: Routes = [
  {
    path: "",
    component: GamesComponent,
    children: [
      { path: "**", redirectTo: "bingo" },
      { path: "bingo", component: BingoComponent },
    ],
  },
  { path: "**", redirectTo: "" },
];

The empty(“”) route loads the standalone GamesComponent and its child route ‘bingo’ with the BingoComponent. The games.component.html:

<mat-toolbar color="primary" class="custom-toolbar">
  <div class="toolbar-content">
    <div i18n="@@bingoGames">Games</div>
    <div class="example-fill-remaining-space"></div>
    <div>
      <button mat-flat-button color="primary" (click)="back()" i18n="@@back">
        Back
      </button>
    </div>
  </div>
</mat-toolbar>
<mat-sidenav-container
  class="container"
  [ngStyle]="{ 'height.px': windowHeight }">
  <mat-sidenav #contact_list1 [mode]="contactListMode" class="contact-list">
    <app-contacts
      [selectedContact]="selectedContact"
      [contacts]="contacts"
      (selContact)="selectContact($event)"
    ></app-contacts>
    hallo
  </mat-sidenav>
  <mat-sidenav-content class="mat-drawer-container">
    <router-outlet></router-outlet>
  </mat-sidenav-content>
</mat-sidenav-container>

The ‘<mat-toolbar …></mat-toolbar>’ contains the header bar with the ‘Games’ heading and the ‘Back’ button that calls the ‘back()’ method to route back.

The ‘<mat-sidename-container …></mat-sidename-container>’ contains the side column in the ‘<mat-sidenav …></mat-sidenav>’ that contains the list of contacts. The ‘<mat-sidenav-container …></mat-sidenav-container>’ contains the ‘<router-outlet></router-outlet>’ to show the BingoComponent of the route.

The games.component.ts:

@Component({
  standalone: true,
  selector: "app-games",
  templateUrl: "./games.component.html",
  styleUrls: ["./games.component.scss"],
  imports: [CommonModule, RouterModule, MatToolbarModule, MatButtonModule,
    MatSidenavModule, ContactsComponent],
})
export class GamesComponent extends BaseComponent 
  implements OnInit, AfterViewInit {

  constructor(private router: Router, mediaMatcher: MediaMatcher,
    localdbService: LocaldbService,jwttokenService: JwtTokenService,
    contactService: ContactService, gamesService: GamesService) {
    super(mediaMatcher, localdbService, jwttokenService, contactService,
      gamesService);
  }

  ngOnInit(): void {
    super.ngOnInit();
  }

  ngAfterViewInit(): void {
    super.ngAfterViewInit();
  }

  @HostListener("window:resize", ["$event"])
  myResize(event: MyEvent): void {
    super.onResize(event);
  }

  get contactList() {
    return this.myContactList;
  }

  protected afterContactsLoaded(): Promise<Message[]> {
    return Promise.resolve([]);
  }

  protected afterContactsAdded(): void {
    this.gamesService.contacts = this.contacts;
    this.gamesService.myUser = this.myUser;
    this.gamesService.selectedContact = this.selectedContact;
    this.gamesService.windowHeight = this.windowHeight;
  }

  @ViewChild("contact_list1")
  set contactList(myContactList: MatSidenav) {
    this.myContactList = myContactList;
  }

  protected updateContactListLayout(
    event: MediaQueryListEvent = null): void {
    super.updateContactListLayout(event);
    this.gamesService.windowHeight = this.windowHeight;
  }

  back(): void {
    this.router.navigate(["/"]);
  }
}

The ‘@Component({…})’ annotation has the ‘standalone’ flag and the ‘imports’ array with the imports for all the used components and modules(formerly imported in the modules). The ‘GamesComponent’ extends the BaseComponent that contains the logic to initialize the contacts and implements the ‘OnInit’ and ‘AfterViewInit’ interfaces. The BaseComponent class contains the common logic of the messenger and the games components that is inherited in both parts. The constructor gets all the classes needed injected by Angular and sets them in the ‘super(…)’ sub constructor call. The implementations of ‘ngOnInit()’ and ‘ngAfterViewInit()’ are used to call the implementations of the subclass BaseComponent. The ‘@HostListener(…)’ annotation connects the ‘ myResize(…)’ method to resize events and calls the implementation of the subclass. The ‘@ViewChild(…)’ annotation is used to inject the MatSideNav in the ‘set contactList(…)’ setter to be used in the subclass the getter provides it to the template. The ‘back()’ method uses the router to navigate to the messenger components.

The bingo.component.html:

<div class="container" 
  [ngStyle]="{ 'height.px': gamesService.windowHeight }">
  <div i18n="@@bingoHeading">Lets play Bingo</div>
  <div *ngIf="gameUuid && !bingoResult" i18n="@@bingoNewNumber">
    New Number:</div>
  <div *ngIf="!gameUuid || bingoResult">
    <button mat-raised-button color="primary" (click)="startGame()"
      i18n="@@bingoStart">Start</button>
  </div>
  <div>{{ bingoNumber > 0 && !bingoResult ? bingoNumber : "-" }}</div>
  <div *ngIf="bingoResult" class="game-won">Won</div>
  <div *ngIf="!bingoResult && bingoNumber <= 0"></div>
  <div *ngIf="!bingoResult && bingoNumber > 0">
    <button mat-raised-button color="primary" (click)="stopGame()"
      i18n="@@bingoStop">Stop</button>
  </div>
  <div></div>
  <div *ngFor="let bingoCell of bingoCells" class="bingo-cell"
    [class.bingo-cell-selected]="bingoCell.hit && !bingoCell.bingo"
    [class.bingo-cell-bingo]="bingoCell.hit && bingoCell.bingo"
    (click)="switchBingoCell(bingoCell)">
    {{ bingoCell.value }}
  </div>
</div>

The ‘<div class=”container” [ngStyle]="{ 'height.px': gamesService.windowHeight }">‘ sets the height of the ‘GamesService’. The container ‘<div class=”container”>…</div>’ uses the Css Grid to display the divs in a grid of 5 columns with the bingo.component.scss. The first row of 5 divs is used to display the new numbers and the buttons to start and stop the game and display the game result.

The bingo cells are displayed with the ‘*ngFor=”…”‘ the Css Grid manages the layout. The selected and bingo cells are managed with the ‘[class.userclassname]=”…”‘ of Angular. The ‘click(…)’ calls the ‘switchCell(…)’ method to switch the hit flag.

export interface BingoCell {
  value: number;
  hit: boolean;
  bingo: boolean;
}

interface NewGame {
  bingoCells: BingoCell[];
  gameUuid: string;
  bingoHits: boolean[][];
}

interface CheckForWinResult {
  win: boolean;
  xrow: number;
  yrow: number;
  plusdiag: boolean;
  minusdiag: boolean;
}

@Component({
  standalone: true,
  selector: "app-bingo",
  templateUrl: "./bingo.component.html",
  styleUrls: ["./bingo.component.scss"],
  providers: [BingoService],
  imports: [CommonModule, MatButtonModule],
})
export class BingoComponent implements OnInit {
  protected bingoCells: BingoCell[] = [];
  protected bingoNumber = 0;
  protected gameUuid: string;
  protected bingoResult = false;
  private randomNumberSub: Subscription = null;
  private readonly destroy: DestroyRef = inject(DestroyRef);
  private gameHits: boolean[][];
  private randomValues: number[] = [];

  constructor(
    protected gamesService: GamesService,
    private bingoService: BingoService
  ) {}

  ngOnInit(): void {
    this.bingoCells = [];
    //console.log(this.gamesService.myUser);
  }

  protected startGame(): void {
    this.stopGame();	
    this.bingoService
      .newGame([this.gamesService.myUser.userId])
      .pipe(
        map((myValue) => this.mapNewGame(myValue)),
        takeUntilDestroyed(this.destroy)
      )
      .subscribe((result) => {
        this.bingoCells = result.bingoCells;
        this.gameUuid = result.gameUuid;
        this.gameHits = result.bingoHits;
        this.randomValues = [];
        this.randomNumberSub = this.bingoService
          .updateGame(this.gameUuid)
          .pipe(repeat({ delay: 5000 }), takeUntilDestroyed(this.destroy))
          .subscribe((result) => this.updateValues(result));
      });
  }

  protected stopGame(): void {
    this?.randomNumberSub?.unsubscribe();
    this.bingoNumber = 0;
    this.bingoResult = false;
    this.randomValues = [];
    const currentGameUuid = this.gameUuid; 
    if(!!currentGameUuid) {
      this.bingoService.endGame(this.gameUuid)
        .pipe(takeUntilDestroyed(this.destroy))
        .subscribe(result => console.log(
          `Game ended: ${currentGameUuid}, result: ${result}`));	
    }
  }

  protected switchBingoCell(bingoCell: BingoCell): void {
    bingoCell.hit = this.randomValues.includes(bingoCell.value) ? 
      !bingoCell.hit : false;
    const checkForWin = this.checkForWin();
    if (checkForWin.win) {
      this.bingoService
        .checkWin(this.gameUuid, this.gamesService.myUser.userId)
        .pipe(takeUntilDestroyed(this.destroy))
        .subscribe((result) => {
          this.bingoResult = result;
          this.markBingo(checkForWin);
          this.randomNumberSub.unsubscribe();
        });
    }
  }

  ... The private methods can be found in the Github repo.

}

The ‘@Component({…})’ sets the standalone flag and the has the imports array for the modules that are used. The ‘BingoComponent’ implements the ‘OnInit’ interface and creates the properties. The ‘randomNumberSub’ is used for the subscription to be able to stop the pipeline. The ‘destroy’ property contains the DestroyRef that is used in the ‘takeUntilDestroyed(…)’ function of the Rxjs pipes. The ‘ngOnInit()’ sets the ‘bingoCells’ array for the bingo grid.

The method ‘startGame()’ first calls the ‘stopGame()’ method and then calles the ‘newGame(…)’ method of the ‘GamesService’ the result is mapped to the ‘NewGame’ interface and the ‘takeUntilDestroyed(…)’ unsubscribes the pipeline when the component is left. The ‘subscribe(…)’ sets the component properties and sets the ‘randomNumberSub’ subscription with the ‘bingoService.updateGame(…)’ pipe to provide the random number stream for the bingo game.

The method ‘stopGame()’ first unsubscribe the ‘randomNumberSub’ subscription to stop and free the pipe generating random numbers. Then the component properties are reset and the ‘currentGameUuid’ is set. If the ‘currentGameUuid’ is set the ‘endGame(…)’ Rxjs pipeline is called to delete the ‘BingoGame’ document from the database.

The method ‘switchBingoCell(…)’ is called if a user clicks on a ‘bingoCell’. It switches the hit value to mark the cell as selected if the ‘bingoCell’ value is in the ‘randomValues’ property from the server. Then it checks the board for a win with the ‘checkWin()’ method of the compoment. The implementation is found in the Github Repo file. If the method returns a win flag then the ‘checkWin(…)’ pipeline is called to have the backend check the board win. If the backend confirms the win the result is set and the winning line is marked with the ‘markBingo(…)’ method and the randomNumberSub is unsubscribed to stop the pipe generating the random numbers.

Conclusion Frontend

Angular supports the implementation of the bingo game very well. The standalone components move the imports of the modules in the components and can make it easier to turn more routes into lazy loaded ones. The ‘DestroyRef’ and the ‘takeUntilDestroyed(…)’ rxjs function make freeing the pipelines much easier. The Grid layout of Css helps a lot with the layout of the bingo board. Finally being able to include the games.routes.ts as lazy routes helps with routing to the standalone components.