Now that we have the server ready, we can start working on the client side. Soon we will finally be able to actually play our game over the network!

This is the third part of the series that will show you how to make a lag compensated multiplayer mode for the arcade classic. In case you missed the previous ones or want to go further, you can check them out below:

The Client

Conceptually, the client will be something that sends Player’s Controls to the server, receives a game state and is able to render it. No movement or collisions will be computed on the client side – instead the server will manipulate game objects like puppets. Later this will change a little as we introduce lag compensation techniques for the client but for now, it will be all.

Infrastructural chores, again

Remember when we’ve introduced server module in the previous part? This will be quite similar, except that new module will be called client and it won’t be runnable (as opposed to desktop). Keep in mind that just like previously, all that work is already done for you in the reference repository, so if you want to go straight into implementing stuff, clone it and just delete everything in the src directories.

Although we could just lump all the client side code into desktop, it would go against LibGDX’s multiplatform nature and we wouldn’t be able to use our universal client side logic in future html, android or ios projects, therefore let’s keep it separated. We’ll go ahead and create the client module, copy build.gradle from core, create /src/com/asteroids/game/client namespace and then we’ll go up to the project root, include client in settings.gradle and open up build.gradle.

There will be one addition and one change in the build process. desktop will no longer depend on core, it will instead depend on client which in turn will depend on core. This is because in a sense, desktop will be a subset of client – it’s one possible client, just like html would be another. Here’s how client will look and how desktop will change:

project(":desktop") { apply plugin: "java" dependencies { compile project(":client") compile "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" } } project(":client") { apply plugin: "java" dependencies { compile project(":core") compile "com.badlogicgames.gdx:gdx-backend-lwjgl:$gdxVersion" compile "io.socket:socket.io-client:0.8.2" } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 project ( ":desktop" ) { apply plugin : "java" dependencies { compile project ( ":client" ) compile "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" } } project ( ":client" ) { apply plugin : "java" dependencies { compile project ( ":core" ) compile "com.badlogicgames.gdx:gdx-backend-lwjgl:$gdxVersion" compile "io.socket:socket.io-client:0.8.2" } }

You’ll notice that we’ve included SocketIO client to be able to talk to our SocketIO server.

Extending mappers

Lastly we’ve introduced a couple of mappers to help tie our Dtos with the rest of the game code. It’s time to extend them a little in order to cover client’s needs. Some of these changes will contain a bit of logic, so contrary to how they were introduced we’ll now go through these changes individually. Changes that we’re going to introduce will have to deal with creating and updating game models. Creation will take place when Dtos coming from the server are describing models not yet present on the client, and update will be performed whenever they already exist. We won’t handle deleting models in mappers, because we won’t need a mapper to perform deletion.

Bullet Mapper

We know that a Bullet belongs to a Player, so in order to introduce new Bullet, we’ll need a Container of available Players passed along BulletDto.

Updating will be easier, as we’ll just set the position of the particular Bullet.

public class BulletMapper { ... public static Bullet fromDto(BulletDto dto, Container<Player> playersContainer) { Player shooter = playersContainer.getById(dto.getShooterId()) .orElseThrow(() -> new RuntimeException("Cannot find Player of id " + dto.getShooterId() + " to create a Bullet.")); return new Bullet(UUID.fromString(dto.getId()), shooter, new Vector2(dto.getX(), dto.getY()), dto.getRotation()); } public static void updateByDto(Bullet bullet, BulletDto dto) { bullet.setPosition(new Vector2(dto.getX(), dto.getY())); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 public class BulletMapper { . . . public static Bullet fromDto ( BulletDto dto , Container <Player> playersContainer ) { Player shooter = playersContainer . getById ( dto . getShooterId ( ) ) . orElseThrow ( ( ) - > new RuntimeException ( "Cannot find Player of id " + dto . getShooterId ( ) + " to create a Bullet." ) ) ; return new Bullet ( UUID . fromString ( dto . getId ( ) ) , shooter , new Vector2 ( dto . getX ( ) , dto . getY ( ) ) , dto . getRotation ( ) ) ; } public static void updateByDto ( Bullet bullet , BulletDto dto ) { bullet . setPosition ( new Vector2 ( dto . getX ( ) , dto . getY ( ) ) ) ; } }

Ship Mapper

When mapping from ShipDto, there’ll be a possibility that a Player won’t have any Ship at the moment, therefore we’ll need to handle a null ShipDto case (which will eventually get transformed into empty Optional through Player’s setter). Otherwise we’ll return brand new Ship. Updating will set Ship’s position and rotation according to ShipDto.

public class ShipMapper { ... public static Ship fromDto(ShipDto dto, Player owner) { if(dto == null) return null; return new Ship(owner, new Vector2(dto.getX(), dto.getY()), dto.getRotation()); } public static void updateByDto(Ship ship, ShipDto dto) { ship.setPosition(new Vector2(dto.getX(), dto.getY())); ship.setRotation(dto.getRotation()); } } 1 2 3 4 5 6 7 8 9 10 11 12 public class ShipMapper { . . . public static Ship fromDto ( ShipDto dto , Player owner ) { if ( dto == null ) return null ; return new Ship ( owner , new Vector2 ( dto . getX ( ) , dto . getY ( ) ) , dto . getRotation ( ) ) ; } public static void updateByDto ( Ship ship , ShipDto dto ) { ship . setPosition ( new Vector2 ( dto . getX ( ) , dto . getY ( ) ) ) ; ship . setRotation ( dto . getRotation ( ) ) ; } }

Player Mapper

We’ve already seen one interesting method in PlayerMapper, remotePlayerFromDto, that was used on the server to map incoming PlayerDtos to Players. Now we’re going to introduce it’s client side counterpart which will also create a Player but there will be two differences.

Firstly, it will construct these new Players with provided Controls .

Secondly, it’ll pass some of the work to ShipMapper in order to deal with Ship-related mapping. updateByDto won’t actually deal with any of Player’s own properties (because we don’t have any that could change during the course of the game), but rather delegate work to ShipMapper based on whether the Player has a Ship or not.

public class PlayerMapper { ... public static Player localPlayerFromDto(PlayerDto dto, Controls controls) { Player player = new Player(UUID.fromString(dto.getId()), controls, Color.valueOf(dto.getColor())); player.setShip(ShipMapper.fromDto(dto.getShipDto(), player)); return player; } public static void updateByDto(Player player, PlayerDto dto) { Optional<Ship> currentShip = player.getShip(); ShipDto shipDto = dto.getShipDto(); if(currentShip.isPresent() && shipDto != null) { ShipMapper.updateByDto(currentShip.get(), shipDto); } else { player.setShip(ShipMapper.fromDto(shipDto, player)); } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class PlayerMapper { . . . public static Player localPlayerFromDto ( PlayerDto dto , Controls controls ) { Player player = new Player ( UUID . fromString ( dto . getId ( ) ) , controls , Color . valueOf ( dto . getColor ( ) ) ) ; player . setShip ( ShipMapper . fromDto ( dto . getShipDto ( ) , player ) ) ; return player ; } public static void updateByDto ( Player player , PlayerDto dto ) { Optional <Ship> currentShip = player . getShip ( ) ; ShipDto shipDto = dto . getShipDto ( ) ; if ( currentShip . isPresent ( ) && shipDto != null) { ShipMapper.updateByDto(currentShip.get(), shipDto); } else { player . setShip ( ShipMapper . fromDto ( shipDto , player ) ) ; } } }

Controls Mapper

Lastly, we’ll extend ControlsMapper. Not much will happen here as we’ll just add a method to dump Controls state into ControlsDto.

public class ControlsMapper { ... public static ControlsDto mapToDto(Controls controls) { return new ControlsDto( controls.forward(), controls.left(), controls.right(), controls.shoot() ); } } 1 2 3 4 5 6 7 8 9 10 11 public class ControlsMapper { . . . public static ControlsDto mapToDto ( Controls controls ) { return new ControlsDto ( controls . forward ( ) , controls . left ( ) , controls . right ( ) , controls . shoot ( ) ) ; } }

You don’t get to choose what skin color you’re born with

We’ll need some way of distinguishing between all the different Players being visible on the same screen. In more serious game that would be a nickname and chosen color, but ain’t nobody got time for that, so we’ll just declare list of possible Colors that a Player can have and assign those at random.

The list will be declared in the Player model:

public class Player implements Identifiable { public static final List<Color> POSSIBLE_COLORS = Collections.unmodifiableList(Arrays.asList( Color.WHITE, Color.GRAY, Color.BLUE, Color.GREEN, Color.ORANGE, Color.LIGHT_GRAY)); ... } 1 2 3 4 5 public class Player implements Identifiable { public static final List <Color> POSSIBLE_COLORS = Collections . unmodifiableList ( Arrays . asList ( Color . WHITE , Color . GRAY , Color . BLUE , Color . GREEN , Color . ORANGE , Color . LIGHT_GRAY ) ) ; . . . }

We’ll also create a Randomize tool inside core/util package:

public class Randomize { public static <Thing> Thing fromList(List<Thing> things) { return things.stream() .skip((int) (things.size() * Math.random())) .findAny() .get(); } } 1 2 3 4 5 6 7 8 public class Randomize { public static <Thing> Thing fromList ( List <Thing> things ) { return things . stream ( ) . skip ( ( int ) ( things . size ( ) * Math . random ( ) ) ) . findAny ( ) . get ( ) ; } }

Moving things around

Some of the things currently being in core won’t really make sense on server side, so we can move them to the client module.

First obvious candidate will be rendering package, we can just move it as a whole.

Secondly, we’ll create controls package inside client module and move KeyboardControls there, as there’s no point to have them on the server.

Connection, again

Having flashbacks from the previous part yet?

We’ll have to introduce connection package one more time, on the client side. It’ll contain Client interface, analogous to the Server, which will declare events and actions that we’ll be able to handle.

public interface Client { void connect(PlayerDto playerDto); void onConnected(Consumer<IntroductoryStateDto> handler); void onOtherPlayerConnected(Consumer<PlayerDto> handler); void onOtherPlayerDisconnected(Consumer<UuidDto> handler); void onGameStateReceived(Consumer<GameStateDto> handler); void sendControls(ControlsDto controlsDto); boolean isConnected(); } 1 2 3 4 5 6 7 8 9 public interface Client { void connect ( PlayerDto playerDto ) ; void onConnected ( Consumer <IntroductoryStateDto> handler ) ; void onOtherPlayerConnected ( Consumer <PlayerDto> handler ) ; void onOtherPlayerDisconnected ( Consumer <UuidDto> handler ) ; void onGameStateReceived ( Consumer <GameStateDto> handler ) ; void sendControls ( ControlsDto controlsDto ) ; boolean isConnected ( ) ; }

Apart from connect and isConnected, these methods should ring a bell. They’ll be very much related to those already declared on the server side, completing the picture of our overall connection scheme.

We’ll need a concrete implementation of Client interface to be able to connect with our SocketIOServer. connect method will be particularly interesting, as it’ll be responsible for conducting full Player initialization before handing control over to game logic. Right after low-level socket connection will be established, we’ll inform the Server who the Player is and wait for IntroductoryStateDto to be sent back to consider ourselves connected. SocketIoClient won’t do much more beyond establishing connection and transforming JSON strings to Dtos, leaving most of the game work for upper layer.

public class SocketIoClient implements Client { private final Socket socket; private ConnectionState state = ConnectionState.NOT_CONNECTED; private Consumer<IntroductoryStateDto> playerConnectedHandler; private Consumer<PlayerDto> otherPlayerConnectedHandler; private Consumer<UuidDto> otherPlayerDisconnectedHandler; private Consumer<GameStateDto> gameStateReceivedHandler; private enum ConnectionState { NOT_CONNECTED, CONNECTING, CONNECTED; } public SocketIoClient(String protocol, String host, int port) { String url = protocol + "://" + host + ":" + port; try { this.socket = IO.socket(url); } catch (URISyntaxException e) { throw new RuntimeException("Wrong URL provided for socket connection: " + url, e); } } @Override public void connect(PlayerDto playerDto) { if(state == ConnectionState.NOT_CONNECTED) { state = ConnectionState.CONNECTING; socket.on(Socket.EVENT_CONNECT, response -> emit(socket, Event.PLAYER_CONNECTING, playerDto)); on(socket, Event.PLAYER_CONNECTED, response -> { String introductoryStateJson = (String)response[0]; playerConnectedHandler.accept(Dto.fromJsonString(introductoryStateJson, IntroductoryStateDto.class)); state = ConnectionState.CONNECTED; setupEvents(); }); socket.connect(); } } @Override public void onConnected(Consumer<IntroductoryStateDto> handler) { playerConnectedHandler = handler; } @Override public void onOtherPlayerConnected(Consumer<PlayerDto> handler) { otherPlayerConnectedHandler = handler; } @Override public void onOtherPlayerDisconnected(Consumer<UuidDto> handler) { otherPlayerDisconnectedHandler = handler; } @Override public void onGameStateReceived(Consumer<GameStateDto> handler) { gameStateReceivedHandler = handler; } @Override public void sendControls(ControlsDto controlsDto) { emit(socket, Event.CONTROLS_SENT, controlsDto); } @Override public boolean isConnected() { return state == ConnectionState.CONNECTED; } private void setupEvents() { on(socket, Event.OTHER_PLAYER_CONNECTED, response -> { String gameStateDtoJson = (String) response[0]; otherPlayerConnectedHandler.accept(Dto.fromJsonString(gameStateDtoJson, PlayerDto.class)); }); on(socket, Event.OTHER_PLAYER_DISCONNECTED, response -> { String playerIdJson = (String) response[0]; otherPlayerDisconnectedHandler.accept(Dto.fromJsonString(playerIdJson, UuidDto.class)); }); on(socket, Event.GAME_STATE_SENT, response -> { String gameStateDtoJson = (String) response[0]; gameStateReceivedHandler.accept(Dto.fromJsonString(gameStateDtoJson, GameStateDto.class)); }); } private void emit(Socket socket, Event eventName, Dto payload) { socket.emit(eventName.toString(), payload.toJsonString()); } private void on(Socket socket, Event eventName, Emitter.Listener handler) { socket.on(eventName.toString(), handler); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 public class SocketIoClient implements Client { private final Socket socket ; private ConnectionState state = ConnectionState . NOT_CONNECTED ; private Consumer <IntroductoryStateDto> playerConnectedHandler ; private Consumer <PlayerDto> otherPlayerConnectedHandler ; private Consumer <UuidDto> otherPlayerDisconnectedHandler ; private Consumer <GameStateDto> gameStateReceivedHandler ; private enum ConnectionState { NOT_CONNECTED , CONNECTING , CONNECTED ; } public SocketIoClient ( String protocol , String host , int port ) { String url = protocol + "://" + host + ":" + port ; try { this . socket = IO . socket ( url ) ; } catch ( URISyntaxException e ) { throw new RuntimeException ( "Wrong URL provided for socket connection: " + url , e ) ; } } @Override public void connect ( PlayerDto playerDto ) { if ( state == ConnectionState . NOT_CONNECTED ) { state = ConnectionState . CONNECTING ; socket . on ( Socket . EVENT_CONNECT , response - > emit ( socket , Event . PLAYER_CONNECTING , playerDto ) ) ; on ( socket , Event . PLAYER_CONNECTED , response - > { String introductoryStateJson = ( String ) response [ 0 ] ; playerConnectedHandler . accept ( Dto . fromJsonString ( introductoryStateJson , IntroductoryStateDto . class ) ) ; state = ConnectionState . CONNECTED ; setupEvents ( ) ; } ) ; socket . connect ( ) ; } } @Override public void onConnected ( Consumer <IntroductoryStateDto> handler ) { playerConnectedHandler = handler ; } @Override public void onOtherPlayerConnected ( Consumer <PlayerDto> handler ) { otherPlayerConnectedHandler = handler ; } @Override public void onOtherPlayerDisconnected ( Consumer <UuidDto> handler ) { otherPlayerDisconnectedHandler = handler ; } @Override public void onGameStateReceived ( Consumer <GameStateDto> handler ) { gameStateReceivedHandler = handler ; } @Override public void sendControls ( ControlsDto controlsDto ) { emit ( socket , Event . CONTROLS_SENT , controlsDto ) ; } @Override public boolean isConnected ( ) { return state == ConnectionState . CONNECTED ; } private void setupEvents ( ) { on ( socket , Event . OTHER_PLAYER_CONNECTED , response - > { String gameStateDtoJson = ( String ) response [ 0 ] ; otherPlayerConnectedHandler . accept ( Dto . fromJsonString ( gameStateDtoJson , PlayerDto . class ) ) ; } ) ; on ( socket , Event . OTHER_PLAYER_DISCONNECTED , response - > { String playerIdJson = ( String ) response [ 0 ] ; otherPlayerDisconnectedHandler . accept ( Dto . fromJsonString ( playerIdJson , UuidDto . class ) ) ; } ) ; on ( socket , Event . GAME_STATE_SENT , response - > { String gameStateDtoJson = ( String ) response [ 0 ] ; gameStateReceivedHandler . accept ( Dto . fromJsonString ( gameStateDtoJson , GameStateDto . class ) ) ; } ) ; } private void emit ( Socket socket , Event eventName , Dto payload ) { socket . emit ( eventName . toString ( ) , payload . toJsonString ( ) ) ; } private void on ( Socket socket , Event eventName , Emitter . Listener handler ) { socket . on ( eventName . toString ( ) , handler ) ; } }

Using the connection

Let’s use SocketIoClient in the client Screen and utilize the connection logic we’ve been working on. AsteroidsClientScreen will be responsible for sending and handling connection events, and applying their results to the game loop. It won’t compute any collisions or positions (yet), but merely be there to render game state received by the server and send Players Controls.

public class AsteroidsClientScreen extends ScreenAdapter { private final Controls localControls; private final Client client; private final Viewport viewport; private final ShapeRenderer shapeRenderer; private final Container<Player> playersContainer; private final Container<Bullet> bulletsContainer; private final ContainerRenderer<Player> playersRenderer; private final ContainerRenderer<Bullet> bulletsRenderer; private Player localPlayer; public AsteroidsClientScreen( Controls localControls, Client client, Viewport viewport, ShapeRenderer shapeRenderer, Container<Player> playersContainer, Container<Bullet> bulletsContainer, ContainerRenderer<Player> playersRenderer, ContainerRenderer<Bullet> bulletsRenderer) { this.localControls = localControls; this.client = client; this.viewport = viewport; this.playersContainer = playersContainer; this.bulletsContainer = bulletsContainer; this.shapeRenderer = shapeRenderer; this.playersRenderer = playersRenderer; this.bulletsRenderer = bulletsRenderer; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class AsteroidsClientScreen extends ScreenAdapter { private final Controls localControls ; private final Client client ; private final Viewport viewport ; private final ShapeRenderer shapeRenderer ; private final Container <Player> playersContainer ; private final Container <Bullet> bulletsContainer ; private final ContainerRenderer <Player> playersRenderer ; private final ContainerRenderer <Bullet> bulletsRenderer ; private Player localPlayer ; public AsteroidsClientScreen ( Controls localControls , Client client , Viewport viewport , ShapeRenderer shapeRenderer , Container <Player> playersContainer , Container <Bullet> bulletsContainer , ContainerRenderer <Player> playersRenderer , ContainerRenderer <Bullet> bulletsRenderer ) { this . localControls = localControls ; this . client = client ; this . viewport = viewport ; this . playersContainer = playersContainer ; this . bulletsContainer = bulletsContainer ; this . shapeRenderer = shapeRenderer ; this . playersRenderer = playersRenderer ; this . bulletsRenderer = bulletsRenderer ; }

Similarly to how we did it earlier, we’ll implement event handlers in show method.

@Override public void show() { 1 2 @Override public void show ( ) {

When connection is established and Server responded with IntroductoryStateDto, we’ll loop through it’s contents to update local Player‘s Ship state and populate Containers with other Players and Bullets. For now we’ll pass NoopControls because Ships will be updated directly from ShipDtos coming from the server rather than any sort of local Controls.

client.onConnected(introductoryStateDto -> { localPlayer = PlayerMapper.localPlayerFromDto(introductoryStateDto.getConnected(), new NoopControls()); playersContainer.add(localPlayer); GameStateDto gameStateDto = introductoryStateDto.getGameState(); gameStateDto.getPlayers().stream() .map(playerDto -> PlayerMapper.localPlayerFromDto(playerDto, new NoopControls())) .forEach(playersContainer::add); gameStateDto.getBullets().stream() .map(bulletDto -> BulletMapper.fromDto(bulletDto, playersContainer)) .forEach(bulletsContainer::add); }); 1 2 3 4 5 6 7 8 9 10 11 12 client . onConnected ( introductoryStateDto - > { localPlayer = PlayerMapper . localPlayerFromDto ( introductoryStateDto . getConnected ( ) , new NoopControls ( ) ) ; playersContainer . add ( localPlayer ) ; GameStateDto gameStateDto = introductoryStateDto . getGameState ( ) ; gameStateDto . getPlayers ( ) . stream ( ) . map ( playerDto - > PlayerMapper . localPlayerFromDto ( playerDto , new NoopControls ( ) ) ) . forEach ( playersContainer : : add ) ; gameStateDto . getBullets ( ) . stream ( ) . map ( bulletDto - > BulletMapper . fromDto ( bulletDto , playersContainer ) ) . forEach ( bulletsContainer : : add ) ; } ) ;

Whenever other Player connects or disconnects, we need to reconcile that with local playersContainer.

client.onOtherPlayerConnected(connectedDto -> { Player connected = PlayerMapper.localPlayerFromDto(connectedDto, new NoopControls()); playersContainer.add(connected); }); client.onOtherPlayerDisconnected(uuidDto -> playersContainer.removeById(uuidDto.getUuid())); 1 2 3 4 5 6 client . onOtherPlayerConnected ( connectedDto - > { Player connected = PlayerMapper . localPlayerFromDto ( connectedDto , new NoopControls ( ) ) ; playersContainer . add ( connected ) ; } ) ; client . onOtherPlayerDisconnected ( uuidDto - > playersContainer . removeById ( uuidDto . getUuid ( ) ) ) ;

When we’ll receive the game state, there will be a couple of interesting things going on. Let’s walk through them. At first, we’ll update existing Players (and subsequently their Ships too), which will be pretty straightforward:

client.onGameStateReceived(gameStateDto -> { gameStateDto.getPlayers().stream() .forEach(playerDto -> playersContainer .getById(playerDto.getId()) .ifPresent(player -> PlayerMapper.updateByDto(player, playerDto))); 1 2 3 4 5 client . onGameStateReceived ( gameStateDto - > { gameStateDto . getPlayers ( ) . stream ( ) . forEach ( playerDto - > playersContainer . getById ( playerDto . getId ( ) ) . ifPresent ( player - > PlayerMapper . updateByDto ( player , playerDto ) ) ) ;

For Bullets we’ll need a bit more work to do, mostly because contrary to Player we don’t have dedicated events for when Bullet was introduced or removed (there would be a lot of them). If Bullet coming from the Server doesn’t exist yet, we’ll need to add it. If it’s there, we’ll need to update it. Finally, if it’s present in our local game but not on the Server, we’ll need to delete it locally.

gameStateDto.getBullets().stream() .forEach(bulletDto -> { Optional<Bullet> bullet = bulletsContainer.getById(bulletDto.getId()); if(!bullet.isPresent()) { bulletsContainer.add(BulletMapper.fromDto(bulletDto, playersContainer)); } else { BulletMapper.updateByDto(bullet.get(), bulletDto); } }); List<String> existingBulletIds = gameStateDto.getBullets().stream() .map(BulletDto::getId) .collect(toList()); bulletsContainer.getAll().stream() .map(Bullet::getId) .map(Object::toString) .filter(id -> !existingBulletIds.contains(id)) .collect(toList()) .forEach(bulletsContainer::removeById); }); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 gameStateDto . getBullets ( ) . stream ( ) . forEach ( bulletDto - > { Optional <Bullet> bullet = bulletsContainer . getById ( bulletDto . getId ( ) ) ; if ( ! bullet . isPresent ( ) ) { bulletsContainer . add ( BulletMapper . fromDto ( bulletDto , playersContainer ) ) ; } else { BulletMapper . updateByDto ( bullet . get ( ) , bulletDto ) ; } } ) ; List <String> existingBulletIds = gameStateDto . getBullets ( ) . stream ( ) . map ( BulletDto : : getId ) . collect ( toList ( ) ) ; bulletsContainer . getAll ( ) . stream ( ) . map ( Bullet : : getId ) . map ( Object : : toString ) . filter ( id - > ! existingBulletIds . contains ( id ) ) . collect ( toList ( ) ) . forEach ( bulletsContainer : : removeById ) ; } ) ;

Why .filter().collect().forEach()instead of just .filter().forEach()? Because you shouldn’t modify Stream’s underlying List during pipeline execution.

We’ll end our work in show by executing connection request for local Player:

client.connect(new PlayerDto(null, Randomize.fromList(Player.POSSIBLE_COLORS).toString(), null)); } @Override public void render(float delta) { Gdx.gl.glClearColor(0, 0, 0, 1); Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT); if(!client.isConnected()) return; client.sendControls(ControlsMapper.mapToDto(localControls)); viewport.apply(); shapeRenderer.setProjectionMatrix(viewport.getCamera().combined); shapeRenderer.begin(ShapeRenderer.ShapeType.Line); playersRenderer.render(shapeRenderer); bulletsRenderer.render(shapeRenderer); shapeRenderer.end(); } @Override public void resize(int width, int height) { viewport.update(width, height, true); } @Override public void dispose() { shapeRenderer.dispose(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 client . connect ( new PlayerDto ( null , Randomize . fromList ( Player . POSSIBLE_COLORS ) . toString ( ) , null ) ) ; } @Override public void render ( float delta ) { Gdx . gl . glClearColor ( 0 , 0 , 0 , 1 ) ; Gdx . gl . glClear ( GL20 . GL_COLOR_BUFFER_BIT ) ; if ( ! client . isConnected ( ) ) return ; client . sendControls ( ControlsMapper . mapToDto ( localControls ) ) ; viewport . apply ( ) ; shapeRenderer . setProjectionMatrix ( viewport . getCamera ( ) . combined ) ; shapeRenderer . begin ( ShapeRenderer . ShapeType . Line ) ; playersRenderer . render ( shapeRenderer ) ; bulletsRenderer . render ( shapeRenderer ) ; shapeRenderer . end ( ) ; } @Override public void resize ( int width , int height ) { viewport . update ( width , height , true ) ; } @Override public void dispose ( ) { shapeRenderer . dispose ( ) ; } }

Client game

As we did before, we’ll configure and inject dependencies in the Game class, this time it will be AsteroidsClientGame:

public class AsteroidsClientGame extends Game { private Screen asteroids; @Override public void create() { Viewport viewport = new FillViewport(WORLD_WIDTH, WORLD_HEIGHT); ShapeRenderer shapeRenderer = new ShapeRenderer(); Controls localControls = new KeyboardControls(); Container<Bullet> bulletsContainer = new BulletsContainer(); Container<Player> playersContainer = new PlayersContainer(); ContainerRenderer<Bullet> bulletsRenderer = new ContainerRenderer(bulletsContainer, VisibleRenderer::new); ContainerRenderer<Player> playersRenderer = new ContainerRenderer(playersContainer, PlayerRenderer::new); Map<String, String> env = System.getenv(); String protocol = env.getOrDefault("PROTOCOL", "http"); String host = env.getOrDefault("HOST", "localhost"); int port = Integer.parseInt(env.getOrDefault("PORT", "8080")); Client client = new SocketIoClient(protocol, host, port); asteroids = new AsteroidsClientScreen( localControls, client, viewport, shapeRenderer, playersContainer, bulletsContainer, playersRenderer, bulletsRenderer); setScreen(asteroids); } @Override public void dispose() { asteroids.dispose(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public class AsteroidsClientGame extends Game { private Screen asteroids ; @Override public void create ( ) { Viewport viewport = new FillViewport ( WORLD_WIDTH , WORLD_HEIGHT ) ; ShapeRenderer shapeRenderer = new ShapeRenderer ( ) ; Controls localControls = new KeyboardControls ( ) ; Container <Bullet> bulletsContainer = new BulletsContainer ( ) ; Container <Player> playersContainer = new PlayersContainer ( ) ; ContainerRenderer <Bullet> bulletsRenderer = new ContainerRenderer ( bulletsContainer , VisibleRenderer : : new ) ; ContainerRenderer <Player> playersRenderer = new ContainerRenderer ( playersContainer , PlayerRenderer : : new ) ; Map < String , String > env = System . getenv ( ) ; String protocol = env . getOrDefault ( "PROTOCOL" , "http" ) ; String host = env . getOrDefault ( "HOST" , "localhost" ) ; int port = Integer . parseInt ( env . getOrDefault ( "PORT" , "8080" ) ) ; Client client = new SocketIoClient ( protocol , host , port ) ; asteroids = new AsteroidsClientScreen ( localControls , client , viewport , shapeRenderer , playersContainer , bulletsContainer , playersRenderer , bulletsRenderer ) ; setScreen ( asteroids ) ; } @Override public void dispose ( ) { asteroids . dispose ( ) ; } }

While we’re at it, we can delete most of AsteroidsGame in core since we won’t need it anymore. What will still be shared between the client and the server are world dimensions, so they’ll be all that’s left in this class:

public class AsteroidsGame { public static final float WORLD_WIDTH = 800f; public static final float WORLD_HEIGHT = 600f; } 1 2 3 4 public class AsteroidsGame { public static final float WORLD_WIDTH = 800f ; public static final float WORLD_HEIGHT = 600f ; }

Naturally, desktop‘s DesktopLauncher will now have to use AsteroidsClientGame instead of AsteroidsGame:

public class DesktopLauncher { public static void main (String[] arg) { LwjglApplicationConfiguration config = new LwjglApplicationConfiguration(); new LwjglApplication(new AsteroidsClientGame(), config); } } 1 2 3 4 5 6 public class DesktopLauncher { public static void main ( String [ ] arg ) { LwjglApplicationConfiguration config = new LwjglApplicationConfiguration ( ) ; new LwjglApplication ( new AsteroidsClientGame ( ) , config ) ; } }

An actual multiplayer

If everything went smooth, you should now be able to run the server and two clients and see that they’re connected. On Linux/macOS it would be ./gradlew server:run and ./gradlew desktop:run, on Windows you’d use gradlew.bat instead of ./gradlew.

Here’s how launch procedure looks from IntelliJ IDEA (click on the image to see it in full screen):







And here’s the game in action:







If you’d like to run the game on different machines in LAN network, be sure to find out the local IP address of the machine that will run the server and export it on both server machine and all the connected clients as HOST environment variable.

That’s it for this part. Next time we’ll introduce proper lag compensation to make the game feel smooth even when there are latencies between clients and the server. See you then!