Multiplayer Architecture for LibGDX Games (2026)
How I built the multiplayer backend for Astral Tournament — a turn-based PvP card game using LibGDX, Kryonet, Spring Boot, and Docker.
Project Structure:
The project is split into five Gradle modules:
// settings.gradle.kts
rootProject.name = "astral-tournament-root"
include(
"astral-tournament-common",
"astral-tournament-server",
"astral-tournament-client:android",
"astral-tournament-client:lwjgl3",
"astral-tournament-client:core"
)
This project runs on LibGDX with KryoNet on the client side. And for the server I use
Spring Boot with MySQL and Liquibase. The server is deployed with Docker, automated by Gitea CI, and monitored with Grafana.
The key insight: there isn't just a client and a server - there's a common submodule shared by both. And it doesn't just hold DTOs. The entire gameplay logic lives here.
The game screen knows nothing about gameplay logic. It receives a list of step-by-step instructions - MatchAction objects — and plays them back in order.
This means both singleplayer and multiplayer run exactly the same code for game simulation. The client's renderer doesn't need to know the difference.
This is how those instructions looks like:
MatchAction[cardName=Demon,index=2,playerUuid=9cde682a-b2e8-4523-a43b-2101bd22a552,syncCounter=0,type=SUMMON]
MatchAction[cardName=Orc,index=0,playerUuid=9cde682a-b2e8-4523-a43b-2101bd22a552,syncCounter=0,type=CARD_ATTACK]
MatchAction[cardName=Orc,index=1,playerUuid=9cde682a-b2e8-4523-a43b-2101bd22a552,syncCounter=0,type=CARD_ATTACK]
MatchAction[cardName=,index=0,playerUuid=7678e05e-e1fd-4edb-9131-23248bc77653,syncCounter=0,type=ROUND_END]
MatchAction[cardName=Sea spirit,index=3,playerUuid=7678e05e-e1fd-4edb-9131-23248bc77653,syncCounter=0,type=SUMMON]
MatchAction[cardName=Assassin,index=1,playerUuid=7678e05e-e1fd-4edb-9131-23248bc77653,syncCounter=0,type=CARD_ATTACK]
MatchAction[cardName=Wyvern,index=2,playerUuid=7678e05e-e1fd-4edb-9131-23248bc77653,syncCounter=0,type=CARD_ATTACK]
MatchAction[cardName=,index=0,playerUuid=9cde682a-b2e8-4523-a43b-2101bd22a552,syncCounter=0,type=ROUND_END]
// GameScreenAnimations.java — queues and renders actions frame-by-frame
public class GameScreenAnimations {
private final Deque<MatchAction> actions = new LinkedList<>();
private final Map<MatchActionType, MatchActionRunner> actionRunners = new HashMap<>();
private boolean actionLock = false;
public void addMatchActions(MatchActions netActions) {
actions.addAll(netActions.actions);
}
public void render(float delta) {
if (!actions.isEmpty() && !actionLock) {
var action = actions.poll();
animationsForAction(action);
}
}
private void animationsForAction(MatchAction action) {
actionLock = true;
actionRunners.getOrDefault(action.type, this::noop).run(action);
}
}
Both paths — network TCP packets and local event bus - converge on gameScreen.addActions(). The renderer doesn't care where the commands come from.
// MatchActionsController.java — unified entry point for rendering commands
@Singleton
public class MatchActionsController implements BiConsumer<Connection, MatchActions> {
private final GameScreen gameScreen;
@Inject
public MatchActionsController(EBus eBus, GameScreen gameScreen) {
this.gameScreen = gameScreen;
eBus.addRunner(LocalMatchActionsEvent.class, this::acceptLocal);
}
// Called when MatchActions arrive from the server via KryoNet TCP
@Override
public void accept(Connection connection, MatchActions actions) {
Gdx.app.postRunnable(() -> gameScreen.addActions(actions));
}
// Called for local (offline) matches via event bus
private void acceptLocal(LocalMatchActionsEvent e) {
Gdx.app.postRunnable(() -> gameScreen.addActions(e.matchActions));
}
}
Two MatchClient Implementations
The MatchClient interface abstracts away the difference between online and offline play:
// MatchClient.java
public interface MatchClient {
void sendMove(Move move);
void leave();
}
Creates a Match locally, uses a bot AI to generate opponent moves, and fires actions onto the event bus.
Bot has one public method Optional<Move> decide(MatchState matchState); if bot, for some reason cant make a decision it just skip turn as fallback.
@Singleton
public class OfflineGameService implements MatchClient {
private Match match;
private Bot bot;
public NewMatchInfo createNewGame() {
String botUuid = UUID.randomUUID().toString();
BaseDeckFactory baseDeckFactory = new BaseDeckFactory(baseCardsStorage);
Hero hero = new Hero(baseDeckFactory, save.uuid());
Hero enemy = new Hero(baseDeckFactory, botUuid);
match = new Match(List.of(hero, enemy), baseCardsStorage, save.uuid());
bot = new MinMaxBot(botUuid, newMatchInfo, baseCardsStorage, WeightsLoader.loadDefault());
return newMatchInfo;
}
@Override
public void sendMove(Move move) {
match.acceptMove(move).ifPresent(it -> {
eBus.fire(new LocalMatchActionsEvent(it));
bot.decide(it.lastAction().state)
.ifPresentOrElse(this::sendMove, () -> sendMove(new Move(0, null, bot.uuid(), MoveType.SKIP)));
});
match.matchResult(false).ifPresent(it -> eBus.fire(new LocalMatchResultEvent(it)));
}
}
Multiplayer — ServerClient
Client talks to the server over TCP.
The ServerClient auto-reconnects every 3 seconds, registers itself on connect, and forwards player moves.
The game screen just calls matchClient.sendMove(move) — it never knows whether it's talking to a local bot or a remote server.
@Singleton
public class ServerClient implements Disposable, MatchClient {
private final ScheduledExecutorService connector = Executors.newSingleThreadScheduledExecutor();
@Inject
public ServerClient(…, MatchActionsController matchActionsController, …) {
connector.scheduleWithFixedDelay(this::init, 1, 3, TimeUnit.SECONDS);
}
public void register() {
client.sendTCP(new RegisterName(save.name, save.uuid(), Main.store));
}
private void init() {
// one-time Kryo + listener setup
Kryo kryo = client.getKryo();
RegisterPackets.register(kryo);
Listener.TypeListener listener = new Listener.TypeListener();
listener.addTypeHandler(NewMatchInfo.class, matchInfoController);
listener.addTypeHandler(MatchActions.class, matchActionsController); //remember match actions controller?
listener.addTypeHandler(MatchResult.class, matchResultController);
client.start();
safeConnect();
}
private void safeConnect() {
if (client.isConnected()) return;
client.connect(3000, ip, tcpPort, udpPort);
register();
}
@Override
public void sendMove(Move move) {
client.sendTCP(move);
}
}
Server Architecture
The Spring Boot server has three main storage services, each with its own thread:
PlayerStorage — tracks connected players
Players who disconnect get a 45-second grace period to reconnect before being cleaned up. If they come back in time, their game resumes automatically.
@Service
public class PlayerStorage {
private final ConcurrentMap<Integer, String> connToUuid = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Player> uuidToPlayer = new ConcurrentHashMap<>();
private final ConcurrentMap<String, ScheduledFuture<?>> pendingRemovals = new ConcurrentHashMap<>();
}
GamesStorage — manages active games
A janitor thread runs every 5 seconds, collects finished games, persists their results, updates Elo ratings, and frees up players.
@Service
public class GamesStorage {
private final Map<Integer, Game> games = new HashMap<>();
private final ScheduledExecutorService janitor = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
private void init() {
janitor.scheduleAtFixedRate(this::clean, 1, 5, TimeUnit.SECONDS);
}
private void clean() {
var forRemoval = games.values().stream()
.filter(Game::isClosed)
.peek(this::removeGame)
.map(it -> it.id)
.collect(Collectors.toSet());
forRemoval.forEach(games::remove);
}
private void removeGame(Game game) {
matchHistoryRepository.save(game.history);
updateElo(game);
game.players.values().forEach(Player::setIdle);
}
}
MatchmakingService — pairs players
Its super-simple, just pairs 2 players that currently queued, creates new game and send game metadata to clients.
The server wraps the same Match class that used in singleplayer in a Game class that adds connection management.
The server just adds: broadcasting results to both players, idle timeout, and disconnect handling.
@Service
public class MatchmakingService {
private final AtomicInteger gamesCreated = new AtomicInteger(0);
private final ScheduledExecutorService updateLoop = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
private void init() {
this.updateLoop.scheduleAtFixedRate(this::update, 3L, 1L, TimeUnit.SECONDS);
}
private void update() {
// PvP: partition queued players into groups of 2
ListUtils.partition(playerStorage.queued().toList(), 2)
.filter(list -> list.size() > 1)
.forEach(this::createGame);
}
private void createGame(List<Player> players) {
var hero1 = new Hero(baseDeckFactory, players.get(0).user.uuid);
var hero2 = new Hero(baseDeckFactory, players.get(1).user.uuid);
var match = new Match(List.of(hero1, hero2), baseCardsStorage, hero1.master);
var game = new Game(match, players, gamesCreated.incrementAndGet());
gamesStorage.add(game, game.id);
players.forEach(p -> p.sendPacket(info));
}
}
The Kryonet Controller Trick
Instead of manually wiring each packet handler, I use a custom annotation and Spring's context scanning:
@Retention(RetentionPolicy.RUNTIME)
public @interface CryoController {
Class value();
}
Mark any Spring bean with @CryoController(Move.class) and it's automatically registered as a Kryonet listener:
@Component
@CryoController(RegisterName.class)
public class RegisterNameController implements BiConsumer<Connection, RegisterName> {
private final UserService userService;
private final PlayerStorage playerStorage;
@Override
public void accept(Connection connection, RegisterName registerName) {
...
}
}
The wiring happens in GameServer.init().
Spring finds all beans with @CryoController, reads the packet class from the annotation's value(), and registers each controller as a Kryonet type handler. Adding a new packet handler means creating one class with two annotations — no boilerplate.
@Component
public class GameServer {
@PostConstruct
public void init() throws IOException {
...
// Auto-discover all @CryoController beans
var controllers = context.getBeansWithAnnotation(CryoController.class).values();
var listener = new Listener.TypeListener();
controllers.forEach(controller -> {
var it = controller.getClass().getAnnotation(CryoController.class).value();
listener.addTypeHandler(it, ((BiConsumer) controller));
});
server.addListener(listener);
}
}
Monitoring - Micrometer + Prometheus + Grafana
Micrometer exposes metrics via Spring Actuator at /actuator/prometheus. Prometheus scrapes them, Grafana visualizes them.
Here example metrics: online players, active games, and new user registrations (tagged by store). Adding a new counter or gauge is a one-liner.
Metrics registration
// PlayerStorage.java
public PlayerStorage(MeterRegistry meterRegistry, GamesStorage gamesStorage) {
Gauge.builder("astral.server.players.online", uuidToPlayer, Map::size)
.description("Current number of players")
.register(meterRegistry);
}
// GamesStorage.java
Gauge.builder("astral.server.games", games, Map::size)
.description("Current number of games")
.register(meterRegistry);
// UserService.java
meterRegistry.counter("astral.server.new.user.counter", "store", store).increment();
Deployment - Docker + Gitea CI
The server Docker image is built with Google Jib, i use jib because gitea act-runner is docker image itself and i dont want to fiddle with docker in docker shenanigans.
Gitea Actions builds and pushes on every push to dev or prod branches:
On the production server when new image created, the running container automatically updates.
No manual SSH commands, i just push to prod, wait five minutes, the update is live.
# .gitea/workflows/build-spring.boot.yml
name: build server and deploy
on:
push:
branches: [dev, prod]
jobs:
build-image:
runs-on: gradle-21
steps:
- name: Clone Repository
run: |
git clone --depth 1 --branch "$BRANCH_NAME" "$REPO_URL" .
- name: Build and push image
run: |
gradle astral-tournament-server:jib \
-Ptag=$BRANCH \
-PdockerUser=${{ secrets.DOCKER_USERNAME }} \
-PdockerPass=${{ secrets.DOCKER_PASSWORD }}
Architecture Summary
Built with LibGDX, Kryonet, Spring Boot 3.2, MySQL, Liquibase, Micrometer, Prometheus, Grafana, Docker, Gitea Actions, and Fastlane. Try Astral Tournament on Google Play