Michał Kmiecik

Michał Kmiecik

Poznań, Polska

I’m a full-time dev building indie startups after hours. I’ve just started making my first small profits — and I’m sharing everything I learn along the way 👇

See behind the scenes ↓

I recently made my first few $ from indie projects. Follow the journey as I build more — and try to make it sustainable.

Building an indie MMORPG – part 6: Player vs Player combat, shared tiles, and new layout

August 1, 2024

6 min read

First version of melee combat between players. Exception-based support for multiple players on one tile. New UI layout with left and right panels. Backend model restructuring and streamlined state management.

Early development screenshot of a 2D MMORPG game I started building

Introduction

Today's post should be a bit shorter than the previous ones. We'll start with an introductory video showcasing the new elements in the game. Apologies for the layout discrepancies, but displaying both windows on one screen is the best way to present the game from both players' perspectives.

First and foremost, there is now the ability to engage in melee combat with other players — ranged combat and magic will be introduced later. Another change that can be noticed is the ability to log in when the starting tile is already occupied (previously, the server did not allow this). This effect was achieved by remodeling the field, which can now store a list of players instead of a single entity. The final change is a slight upgrade to the entire UI: firstly, I gave it a brown background; secondly, I introduced left and right sections that will be responsible for specific functions in the game; and finally, I introduced orange coloring for the player's own messages in the chat.

New approach to state management on the backend

Before we get into the details, I'll mention the new way of modeling the state on the backend. Previously, we had a map where the key was the player's ID, and the value was their position. Now we have a map where the key remains the player's ID, but the value is an entire object that stores information about the position. Additionally, everything is wrapped in a dedicated class to ensure consistency by calling specific methods. Furthermore, a method returning the number of players online has been introduced, which is used every time someone connects or disconnects to notify clients of the new value. The code is below:

@RequiredArgsConstructor
public class GameState {

    private final Map<Position, Field> gameMap;
    private final Map<Short, Player> players;

    public void addPlayer(Player player) {
        players.put(player.getId(), player);
        gameMap.get(player.getPosition()).addPlayer(player);
    }

    public Player removePlayer(short playerId) {
        final var removedPlayer = players.remove(playerId);
        gameMap.get(removedPlayer.getPosition()).removePlayer(playerId);
        return removedPlayer;
    }

    public void changePlayerPos(Player player, Position newPos) {
        gameMap.get(player.getPosition()).removePlayer(player.getId());
        player.updatePosition(newPos);
        gameMap.get(player.getPosition()).addPlayer(player);
    }

    public Field getFieldByPosition(Position position) {
        return gameMap.get(position);
    }

    public Player getPlayerById(short playerId) {
        return players.get(playerId);
    }

    public Set<Player> getPlayersByPosition(Position position) {
        return getFieldByPosition(position).getAllPlayers();
    }

    public short getPlayersOnline() {
        return (short) players.size();
    }
}

Combat

The command to attack a player boils down to sending the coordinates of the tile right-clicked to the server. In fact, this command will also handle all other actions that involve right-clicking a tile – such as opening a container, using a potion, flipping a lever, or performing an action on any other item. From the client, information is sent about "using" the tile at specific coordinates (and of course, the ID of the executor, as in any other command), and the server performs a specific action based on what is on that tile.

Let's look at the code of the manager that dispatches specific tasks based on the situation on the given tile (for now, we only handle the attack):

@RequiredArgsConstructor
public class DoUseActionManager {

    private final GameState gameState;
    private final FightManager fightManager;

    public void doAction(short executorId, PositionView posToUseView) {
        final var posToUse = Position.of(posToUseView);
        final var fieldToUse = gameState.getFieldByPosition(posToUse);
        final var currentPlayer = gameState.getPlayerById(executorId);

        if (fieldToUse.hasOtherPlayer(executorId)) {
            if (fightManager.hasAlreadyThisTarget(currentPlayer, fieldToUse.getAllPlayersIds())) {
                fightManager.clearTarget(currentPlayer);
            } else {
                final var playerToAttack = fieldToUse.getFirstPlayer();
                fightManager.setNewTarget(currentPlayer, playerToAttack);
                if (currentPlayer.getPosition().isNextTo(posToUse)) {
                    fightManager.startAttack(currentPlayer, playerToAttack);
                }
            }

            return;
        }

        fightManager.clearTarget(currentPlayer);

        if (currentPlayer.getPosition().isNextTo(posToUse)) {
            // no action
        } else {
            // field is too far (maybe player should start walk to this field?)
        }
    }
}

If the used tile contains another player, we check if the opponent is already our target. If they are, we clear the attack target (interrupting the attack). If not, we set a new target and immediately start the attack if the victim is on an adjacent tile (remember: for now, only melee weapons).

FightManager is responsible for the damage calculation algorithm and for scheduling subsequent strikes using GameTasksScheduler.

Multiple players on one tile

Let's start by noting that the game usually does not allow multiple players to occupy the same tile (and in the future, this will also apply to monsters). If we try to move to an occupied tile, the game will not allow it. However, there are certain situations, like logging in, where we need to permit such an exception. For this reason, I had to remodel the field object:

public class Field {

    private final Map<Short, Player> players;

    // other fields and constructors

    public void addPlayer(Player player) {
        this.players.put(player.getId(), player);
    }

    public void removePlayer(short playerId) {
        this.players.remove(playerId);
    }

    public boolean canWalkHere() {
        return tile.typeId() != Tile.WATER.typeId() && this.players.isEmpty();
    }

    public Set<Short> getAllPlayersIds() {
        return new HashSet<>(this.players.keySet());
    }

    public boolean hasOtherPlayer(short executorId) {
        return this.players.keySet()
                .stream()
                .anyMatch(it -> it != executorId);
    }

    public Player getFirstPlayer() { // todo better use Optional<>
        if (this.players.isEmpty()) {
            return null;
        }

        return new ArrayList<>(this.players.values()).get(0);
    }

    public Set<Player> getAllPlayers() {
        return new HashSet<>(this.players.values());
    }

    // other methods
}

On the Phaser side, the changes are analogous:

export class Field {
 
  private otherPlayers: OtherPlayer[];

  // other fields and constructor

  addOtherPlayer(otherPlayer: OtherPlayer) {
    this.otherPlayers.push(otherPlayer);
  }

  removePlayer(otherPlayerId: number): OtherPlayer | null {
    const playerToRemove = this.getPlayerById(otherPlayerId);
    if (playerToRemove) {
      const index = this.otherPlayers.findIndex(
        (it) => it.getId() === otherPlayerId
      );

      if (index !== -1) {
        this.otherPlayers.splice(index, 1);
      }

      return playerToRemove;
    }

    return null;
  }

  getPlayerById(playerId: number): OtherPlayer | null {
    return this.otherPlayers.find((it) => it.getId() === playerId) || null;
  }

  getAllPlayers(): OtherPlayer[] {
    return this.otherPlayers;
  }

  // other methods
}

New layout

Finally, a few words about the new layout. For now, we have something like this:

Screen z aplikacji dzwigowydyspozytor.pl

The left panel will primarily display player statistics, while the right panel will show the inventory. Of course, there will be other elements added, but at this moment, I am not sure how everything will be arranged. This will probably evolve based on player feedback. Below, I am including the code:

    <div className="flex flex-col w-screen h-screen">
      <div className={`flex game-panel-h border-8 border-lightbrown`}>
        <Panel l={true}>
          <p className="text-sm break-all">{`Server IP: ${ip}`}</p>
          <p className="text-sm break-all">{`Players online: ${onlinePlayersAmount}`}</p>
        </Panel>
        <PhaserGame initData={initData} />
        <Panel l={false}>Right panel</Panel>
      </div>
      <Chat currentPlayerId={initData!.currentPlayer.id} messages={messages} />
      <YouAreDeadDialog isOpen={showDeadDialog} onClose={handleConfirmDead} />
    </div>
type Props = {
  children: ReactNode;
  l: boolean;
};

const Panel = ({ children, l }: Props) => (
  <div
    className={`flex flex-col gap-4 text-white ${
      l ? "border-r-2" : "border-l-2"
    } border-lightbrown bg-brown p-6 flex-1`}
  >
    {children}
  </div>
);