Here is a very rough minimal example with Spring WebSocket + STOMP.

1. Config

@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*")
    }

    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        registry.enableSimpleBroker("/topic", "/queue")
        registry.setApplicationDestinationPrefixes("/app")
    }
}

2. Incoming move DTO

data class SubmitMoveMessage(
    val matchId: String,
    val expectedVersion: Long,
    val proposedBoardGroups: List<BoardGroupDto>,
    val proposedRackTiles: List<TileDto>
)

3. Outgoing event DTO

data class MatchStateUpdatedEvent(
    val matchId: String,
    val version: Long,
    val boardGroups: List<BoardGroupDto>,
    val currentPlayerId: String
)

4. WebSocket controller

@Controller
class MatchSocketController(
    private val matchService: MatchService,
    private val messagingTemplate: SimpMessagingTemplate
) {
    @MessageMapping("/matches/{matchId}/move")
    fun submitMove(
        @DestinationVariable matchId: String,
        message: SubmitMoveMessage
    ) {
        val updatedState = matchService.submitMove(matchId, message)

        messagingTemplate.convertAndSend(
            "/topic/matches/$matchId",
            MatchStateUpdatedEvent(
                matchId = updatedState.matchId,
                version = updatedState.version,
                boardGroups = updatedState.boardGroups,
                currentPlayerId = updatedState.currentPlayerId
            )
        )
    }
}

5. Service stub

@Service
class MatchService {
    fun submitMove(matchId: String, message: SubmitMoveMessage): MatchState {
        // load current match
        // validate move
        // apply move
        // persist new state
        return MatchState(matchId, 2, emptyList(), "player-2")
    }
}

6. Client flow

That is the basic pattern: