Serious Sam lidava com hordas de inimigos por conexão de modem 56k
(staniks.github.io)- A Serious Engine 1 colocava single-player, multiplayer e reprodução de demo sobre a mesma simulação determinística, registrando e transmitindo ações dos jogadores e blocos do fluxo do jogo em vez do estado completo a cada tick
- Demo e multiplayer compartilham um estado inicial do jogo e depois aplicam deltas de entrada como
CPlayerAction, então a deterministicidade da lógica do jogo e da semente de números aleatórios é a base da sincronização - A camada de rede tratava diretamente números de sequência, ACK, retransmissão, limitação de largura de banda e buffers por conexão sobre UDP para manter a jogabilidade mesmo em linhas lentas
CNetworkMessageoferece serialização, compressão LZ77/LZRW1 e codificação delta baseada em XOR, mas as mensagens, incluindo chat, não são criptografadas e podem receber apenas compressão- O modelo cliente-servidor de Serious Sam economiza banda ao fazer cada cliente manter sua própria simulação, mas exige mecanismos auxiliares como verificação de sincronização, predição, retransmissão e checagem de CRC
Estrutura de simulação comum da Serious Engine
- O código-fonte da Serious Engine 1 foi aberto em 2016 sob a GNU GPL v2, e a análise se baseia na leitura e depuração desse código publicado
- Serious Sam foi projetado desde o início como um jogo multiplayer, e até a campanha single-player funciona internamente como um caso especial da estrutura multiplayer
- Os modos suportados pela engine são os seguintes
- campanha single-player offline
- jogo cooperativo online, LAN e local, além de vários modos de jogo
- tela dividida com vários jogadores em um único cliente
- gravação e reprodução de demo
Gravação de demo: registrar ações em vez do estado completo
- Salvar o estado completo do jogo a cada tick deixaria o arquivo grande, então a Serious Engine salva uma vez o estado completo do jogo no início da gravação e depois registra blocos do fluxo do jogo a cada tick
- Os blocos do fluxo do jogo incluem os seguintes tipos de mensagem
MSG_SEQ_ALLACTIONS: ações dos jogadoresMSG_SEQ_ADDPLAYER: adicionar jogadorMSG_SEQ_REMPLAYER: remover jogadorMSG_SEQ_PAUSE: pausar ou retomarMSG_SEQ_CHARACTERCHANGE: alterar atributos do personagem do jogador
- O elemento central é
MSG_SEQ_ALLACTIONS, e a engine desserializa um objetoCPlayerActionpara cada jogador ativo e o aplica aCPlayerTarget CPlayerActioncontém o estado do jogador- velocidade de movimento em coordenadas do mundo
pa_vTranslation - rotação do personagem em coordenadas do mundo
pa_aRotation - rotação da visão em coordenadas do mundo
pa_aViewRotation - botões pressionados no momento
pa_ulButtons - timestamp em milissegundos baseado em TSC
pa_llCreated
- velocidade de movimento em coordenadas do mundo
- Na reprodução, o estado inicial do jogo é lido e as ações dos jogadores de cada tick são aplicadas como se fosse uma partida real
Por que a deterministicidade é necessária
- Essa estrutura parte da premissa de que tudo no jogo é totalmente previsível e que apenas as ações dos jogadores alteram o jogo
- Até os números aleatórios são tratados com um gerador pseudoaleatório que usa uma semente como parte do estado do jogo
CEntity::IRnd()usaCSessionState::Rnd()ses_ulRandomSeedé inicializado durante a desserialização do estado do jogo
- Se um gerador realmente aleatório ou sementes diferentes forem usados, a reprodução da mesma demo pode produzir resultados diferentes, levando a desincronização
Ponto flutuante e processamento por tick
- Como a versão de PC de Serious Sam foi lançada originalmente só para Windows, a suposição de mesmo compilador e mesmo runtime ajudava a reduzir problemas de sincronização de ponto flutuante
- O renderizador fica em uma DLL, e chamadas a APIs OpenGL ou DirectX podem mudar a precisão da FPU, então a Serious Engine usa guardas de precisão como
CSetFPUPrecision FPUPrecision(FPT_24BIT) - Não foi encontrado um ponto que configure explicitamente o controle de arredondamento, mas há asserts que verificam o estado
_RC_NEARcom_controlfp - A lógica do jogo é separada do frame rate de renderização
- a renderização varia conforme hardware e configuração, e aparentemente é limitada internamente a 500 FPS
- a lógica do jogo fica fixa em 20 ticks por segundo
- O movimento suave é produzido por interpolação linear entre o tick atual e o anterior, e a interpolação pode ser desativada no console com
/net_bLerping=0
Camada própria de pacotes sobre UDP
- No multiplayer da Serious Engine ainda existem nomes de função como
StartPeerToPeer_t, mas o modelo real é cliente-servidor - O servidor recebe e processa mensagens dos clientes e repassa as informações relevantes a todos os clientes
- Cada jogador executa sua própria simulação, de forma parecida com o sistema de demo, e avança o estado ao receber informações sobre as ações dos outros jogadores
- A rede usa UDP e coloca por cima um protocolo próprio para lidar com pacotes fora de ordem e perdas
CPacketgerencia ordem e confiabilidade dos pacotespa_ulSequence: número de sequência para ordenação e remoção de duplicataspa_ubReliable: flag de confiabilidadepa_ubRetryNumber: acompanha a contagem de retransmissõespa_tvSendWhen: usado para horário de envio e controle de congestionamento
- Pacotes confiáveis aguardam ACK e são retransmitidos se o ACK não chegar
- o número máximo de tentativas é definido por
net_iMaxSendRetries, com valor padrão aparentemente10 - o intervalo entre retransmissões é definido por
net_fSendRetryWait, com valor padrão aparentemente0.5f
- o número máximo de tentativas é definido por
- Pacotes confiáveis podem formar um fluxo dividido em vários pacotes
- o primeiro pacote recebe
UDP_PACKET_RELIABLE_HEAD - o último pacote recebe
UDP_PACKET_RELIABLE_TAIL - um pacote confiável único carrega as duas flags
- o primeiro pacote recebe
- Pacotes não confiáveis não formam fluxo, porque a perda de um deles pode quebrá-lo
Ciclo de vida da conexão e roteamento de pacotes
CCommunicationInterfaceé responsável pela comunicação da camada de pacotes e tem funções de interface para servidor, cliente e broadcast- As interfaces de servidor e cliente assumem que o destino da conexão já é conhecido, e a interface de broadcast é usada para envio e recepção com endereços arbitrários
CCommunicationInterfacemantém dois buffers mestrescci_pbMasterInput: desserializa pacotes UDP recebidos emCPackete os armazenacci_pbMasterOutput: serializaCPacketa enviar e o transmite pela API de socket
- A abstração real de comunicação por cliente fica a cargo de
CClientInterface- o servidor mantém uma interface para cada jogador no array
cm_aciClients - o cliente se comunica com o servidor por
cm_ciLocalClient - tanto cliente quanto servidor usam
cm_ciBroadcastpara estabelecer conexão
- o servidor mantém uma interface para cada jogador no array
adr_uwIDdeCAddressé usado como identificador único do cliente ou como marca de pacote de broadcast- se o valor for
'//'ou0, trata-se de um pacote de broadcast - qualquer outro valor é o ID do cliente dentro da sessão
- se o valor for
Estabelecimento de conexão e mecanismos básicos de segurança
- Para se conectar ao servidor, o cliente envia um pacote confiável de broadcast com a flag
UDP_PACKET_CONNECT_REQUEST - Se já houver um cliente conectado com o mesmo endereço e porta, o servidor ignora a solicitação
- Se for um novo cliente, o servidor procura uma interface de cliente vazia e faz o seguinte
- gera um identificador único para esse cliente
- envia o identificador ao cliente em um pacote confiável de broadcast
UDP_PACKET_CONNECT_RESPONSE
- O identificador não usa apenas um índice fixo; ele combina parte de um valor de timer com o índice do cliente
- Como é preciso acertar o
uwIDpara se passar por outro jogador, a superfície de ataque diminui - Se um jogador não conectado enviar um pacote que não seja de broadcast, a Serious Engine pode emitir um aviso no console
Single-player e demo como casos especiais de conexão local
- Single-player e reprodução de demo também têm internamente servidor e cliente, mas ambos rodam no mesmo processo
- Como não há necessidade de usar sockets entre componentes no mesmo processo,
Client_OpenLocal()conecta diretamente a interface de cliente local e a interface do lado do servidor - Os dois
CClientInterfaceconectados movem pacotes do buffer de saída de um lado para o buffer de entrada do outro comExchangeBuffers - O jogo local não precisa passar pelos buffers mestres de entrada e saída nem pelos sockets reais de rede
Camada de mensagens de rede
CNetworkMessageé a abstração de mensagem acima dos pacotes e pode ser lido e escrito como um stream- As mensagens são serializadas e desserializadas com
Read,Write,ReadBits,WriteBitse os operadores<<e>> - Também podem conter submensagens, e depois de escrever os dados necessários é possível ajustar o tamanho do buffer ao tamanho real dos dados com
Shrink - O buffer de
CNetworkMessageé alocado porAllocMemory, que aparentemente chamamallocinternamente CLinearAllocatorexiste, mas não foi encontrado uso dele, e os buffers de mensagem são alocados e realocados com frequência
Compressão e codificação delta
- Mensagens podem ser comprimidas com o compressor especificado ou com o compressor padrão para cada tipo de mensagem
MESSAGETYPEusa os 6 bits inferiores para o tipo e os 2 bits restantes para o método de compressão- LZ77
CzlibCompressor - LZRW1
CLZCompressor - sem compressão
- LZ77
- A compressão padrão parece ser LZRW1, e isso pode ser alterado pela variável de shell
net_iCompression CPlayerActionnão é enviado diretamente; em vez disso, é transmitido um delta obtido por XOR entre a ação atual e a ação anterior- No lado receptor, o delta é novamente aplicado por XOR à última ação para restaurar o
CPlayerActionoriginal - O delta comprime melhor quando as mudanças nos dados são pequenas
- teclas pressionadas costumam permanecer iguais por vários frames
- velocidade e rotação da visão também não variam por toda a faixa de ponto flutuante
- Quando o servidor envia várias ações de jogadores de uma vez com
MSG_SEQ_ALLACTIONS, o efeito desse método pode ser ainda maior
Criptografia de mensagens e chat
- As mensagens da Serious Engine não são criptografadas
- Se a compressão for desativada com
net_iCompression=0, as mensagens de chat do jogo ficam visíveis em texto puro no payload dos pacotes UDP - Em situações reais, se a compressão estiver ativada, quem interceptar os pacotes precisa identificar e descomprimir o fluxo LZ, mas os dados necessários estão dentro dos próprios pacotes
- Na época, muitos jogos não lidavam com criptografia, e implementar mecanismos como autenticação e troca de chaves poderia aumentar a complexidade
- Na web daquela época, a maioria dos sites também ainda usava HTTP
Camada de sessão de jogo
CNetworkLibrary, apesar do nome, gerencia a sessão do jogo, incluindo o estadoCSessionStateCNetworkLibraryherda doCMessageDispatchermencionado anteriormente- Ao iniciar o servidor, a engine executa o seguinte procedimento
- inicializa a coleta de CRC para preparar a verificação de que os clientes conectados têm os mesmos arquivos do servidor
- cria um novo
CSessionState, serializa esse estado e o salva como estado padrão emga_pubDefaultState - carrega a instância local do mundo
- inicializa a interface global de comunicação
- inicializa o estado da sessão local para que, quando clientes se conectarem, seja possível enviar o delta de estado entre o estado padrão e o estado atual do servidor
- encerra a coleta de CRC e salva o valor em
ga_ulCRC
- A checagem de CRC está mais próxima de detecção precoce de desincronização do que de um sistema antitrapaça
- O processo de entrada do cliente segue o fluxo abaixo
- inicializa um estado de sessão local vazio e a interface de comunicação
- envia
MSG_REQ_CONNECTREMOTESESSIONSTATEcom versão da build, nome do modo, senha do servidor, número de jogadores locais eCSessionSocketParams - recebe
MSG_REP_CONNECTREMOTESESSIONSTATEcom mensagem, nome do arquivo de mundo, flags de dificuldade e modo de jogo e propriedades da sessão - inicializa o estado-base do jogo
- envia
MSG_REQ_STATEDELTApara pedir a diferença em relação ao estado atual do servidor - depois de receber
MSG_REP_STATEDELTA, reconstrói o stream do estado do jogo com um diff reverso - inicializa o estado da sessão local com
CSessionState::Read_t() - executa a verificação de CRC e encerra a conexão em caso de divergência
Loop principal e retransmissão do fluxo do jogo
- Os loops principais de cliente e servidor são em grande parte parecidos, mas o servidor executa tarefas adicionais
- O loop atualiza a interface do cliente local e a interface de broadcast, e o estado da sessão local processa as mensagens de rede recebidas
- O servidor também cuida da troca de buffers entre interfaces de cliente pareadas, da atualização das interfaces de cliente do lado do servidor, da atualização do GameAgent e do processamento de comandos de shell de administração remota
SessionStateLoop()separa o tratamento entre mensagens não confiáveis e confiáveis- não confiáveis:
MSG_GAMESTREAMBLOCKS,MSG_KEEPALIVE,MSG_INF_PINGS,MSG_CHAT_OUT - confiáveis:
MSG_INF_DISCONNECTED,MSG_ADMIN_RESPONSE
- não confiáveis:
MSG_GAMESTREAMBLOCKSé uma mensagem não confiável, mas a perda dela pode quebrar a sincronização- A Serious Engine verifica sequências ausentes durante o processamento do fluxo do jogo e solicita retransmissão
- se o próximo bloco de sequência esperado existir, ele é processado
- se o próximo bloco não existir e também não houver blocos mais novos, nada é feito naquele loop
- se o próximo bloco não existir, mas houver um bloco mais novo, define-se um timeout por possível perda
- após o timeout,
MSG_REQUESTGAMESTREAMRESENDsolicita a sequência ausente e a quantidade de blocos
- O servidor reenviará os blocos do fluxo do jogo que forem solicitados
Processamento de blocos do fluxo do jogo
MSG_SEQ_ADDPLAYERé enviado quando um jogador entra na partida e inclui o índice do jogador e um descritorCPlayerCharacterMSG_SEQ_REMPLAYERé enviado quando o jogador se desconecta e contém apenas o índice do jogadorMSG_SEQ_CHARACTERCHANGEtransmite mudanças de nome, time e aparência do jogador- em Serious Sam, o buffer de aparência contém uma estrutura
CPlayerSettings - ela inclui nome do arquivo do modelo do jogador, política de seleção automática de arma, tipo de mira e várias flags
- em Serious Sam, o buffer de aparência contém uma estrutura
MSG_SEQ_PAUSEtransmite pausa ou retomada e imprime no console o nome do jogador que solicitou issoMSG_SEQ_ALLACTIONScontém o tempo do tick atual e as ações de todos os jogadores- aplica
CPlayerActiona cadaCPlayerTarget - depois executa timers, eventos, entidades móveis e processamento de física
- aplica
- A verificação de sincronização é feita por
MakeSynchronisationCheck()CSyncChecké criado viaChecksumForSync()de entidades, alvos de jogador e outros elementos- o cliente envia
MSG_SYNCCHECKao servidor, e a conexão é encerrada se houver divergência em relação ao estado do servidor
Predição para reduzir atraso de entrada
- A predição existe para reduzir a sensação de lentidão em jogos rápidos causada pela latência da internet
- A predição do jogador local usa as ações enviadas ao servidor
- A predição de jogadores remotos usa a última ação recebida do servidor
- Se o cliente avançar diretamente o estado real do jogo sem esperar a resposta do servidor, ele não saberá as ações dos outros jogadores e a sincronização pode quebrar
- Para não misturar estado real e estado previsto, a Serious Engine usa predictor
- o predictor é algo próximo de uma cópia “fantasma” ligada à entidade comum
- o temporary predictor é criado durante a predição e não tem entidade correspondente no estado real do jogo
- Ao processar ticks de predição, apenas entidades predictor são processadas
- Quando o cliente recebe ações dos jogadores vindas do servidor, ele destrói os predictors existentes e inicia um novo ciclo de predição
- Na renderização, a entidade original em predição não é desenhada; o predictor é desenhado no lugar, para dar a impressão de movimento contínuo sem alterar muito o estado real
- O jogador local só pode prever até a quantidade de ações enviadas ao servidor armazenadas em
plt_abPrediction - Na predição de jogadores remotos, se
cli_bLerpActionsestiver desativado, a última ação recebida é repetida - Se
cli_bLerpActionsestiver ativado, a engine faz interpolação linear entre as duas últimas ações, mas o padrão é deixá-lo desativado
Comparação com Doom e Quake
- A rede de Doom era na prática peer-to-peer, mas os clientes trocavam estruturas parecidas com
CPlayerActione cada um rodava sua própria simulação independente - Doom também usava um sistema semelhante para gravação e reprodução de demo
- Quake adotou outra estrutura, mais próxima de um modelo em que o cliente não executa grande parte da lógica do jogo e recebe atualizações de estado do servidor
- O modelo de Quake exige menos preocupação com sincronização e também pode facilitar antitrapaça, por exemplo quando o servidor não envia informações de entidades atrás da parede
- Em Serious Sam, sessões com muito mais inimigos e objetos ativos do que em Quake eram comuns, então enviar o estado de muitos objetos a cada tick poderia impor um custo alto de largura de banda
Portabilidade e limitações estruturais
- Algumas mensagens de rede serializam estruturas de forma próxima a um
reinterpret cast - Isso pode funcionar quando se assume um único compilador e uma única plataforma, mas em jogos multiplataforma o layout e o padding das estruturas podem variar
- Executáveis de 32 bits podem tentar alinhar em fronteiras de 4 bytes, enquanto executáveis de 64 bits podem tentar alinhamento em 8 bytes
- Também há questões de endianness
- PCs x86 usam little-endian
- o PS3 usa big-endian
- A estrutura da Serious Engine é elegante por abstrair, para a lógica do jogo, a diferença entre meios de transporte como rede e arquivo, mas o modelo em que todos os clientes mantêm uma cópia do estado do jogo abre espaço para trapaças
- Por exemplo, um cliente modificado poderia destacar o contorno de outros jogadores atrás de paredes em partidas de deathmatch
1 comentários
Comentários do Hacker News
Fui um dos desenvolvedores responsáveis pela implementação do código de rede de Serious Sam
Eu frequentemente dormia debaixo de uma mesa no escritório da Croteam e ficava fuçando a Usenet, especialmente textos que explicavam o sistema de predição de QuakeWorld, que me inspiraram
Naquela noite, enquanto meu colega Dan testava usando uma velha máquina Unix 486 como roteador para simular latência, eu programei uma implementação mínima e simples
Isso foi muito antes de o jogo de verdade ser construído em cima dela
Ainda ouço aquele som
Sempre gostei muito de jogos cooperativos e de tiro, mas meus amigos só queriam jogar Counter-Strike
Graças a Serious Sam, às vezes eu conseguia convencê-los a jogar algo de que eu gostava também
Ele foi uma pessoa muito importante na minha vida, e jogar os jogos de que ele gostava é uma boa forma de manter viva a memória dele
Um ótimo jogo, um ótimo multiplayer e lembranças muito boas
Serious Sam sempre foi um forte jogo para LAN party
Não porque fosse o título mais vistoso da época, nem porque alguém tivesse planejado isso com antecedência
Quando outros jogos morriam por causa de problemas de driver, aquecimento, atualizações e afins, você abria Serious Sam e ele simplesmente funcionava, então dominava as LAN parties
Isso continuou nas continuações, e mesmo que o PC de alguém estivesse completamente ferrado, ele ainda oferecia suporte estável a tela dividida e lidava bem com os dispositivos de entrada
A parte sistêmica do jogo era realmente excelente em termos de confiabilidade
De forma parecida, Counter-Strike não tinha gráficos incríveis, mas rodava bem até em PCs torradeira, e por isso ficou popular por tanto tempo
Era o único jogo de tiro em primeira pessoa que rodava de forma consistente nos PCs de trabalho, e era muito divertido
Naquela época, QA e suporte técnico se sobrepunham bastante na EA; no verão, o pessoal do suporte trabalhava como beta testers internos para os lançamentos de fim de ano, e no inverno, quando as ligações aumentavam perto do Natal, voltavam ao suporte técnico
Quando implementei multiplayer no port de Game Boy Color de Vigilante 8, usei jogabilidade determinística
O cabo link do GBC enviava e recebia 1 byte simultaneamente em duas direções, funcionando como um par de registradores de deslocamento que se preenchiam mutuamente através do cabo
O jogo era fixado à taxa de quadros do GBC e, na prática, muita atualização de tela precisava acontecer a cada V-Blank; se isso fosse perdido, a rolagem suave quebrava
No início do multiplayer, trocávamos as seeds, e a execução funcionava assim: no quadro A, lia-se a entrada e ela era comprimida em 1 byte e colocada no buffer de transmissão. Enquanto o quadro B era renderizado, a transmissão acontecia. No começo do quadro C, você então tinha a entrada local enviada no quadro A e a entrada do oponente recebida no quadro B
Essas entradas eram aplicadas ao estado do jogo e o quadro C era renderizado, então tanto a entrada local quanto a remota eram aplicadas com 1 quadro de atraso
O jogo local não tinha atraso de entrada, então, se você perdeu no multiplayer, pode culpar a latência e, se quiser, pode me culpar especificamente
É um jogo muito bom, e a explicação técnica da implementação do multiplayer também é excelente
A Croteam é realmente uma equipe de desenvolvimento de jogos muito talentosa
Eu gostei muito de The Talos Principle 1 e 2, e no primeiro eles foram um dos pioneiros a criar cedo um motor de jogo Vulkan totalmente customizado
Fico me perguntando se é a mesma ideia de “1500 arqueiros em 28.8K” de Age of Empires
https://www.gamedeveloper.com/programming/1500-archers-on-a-...
Muitos jogos usaram esse sistema por muito tempo, mas hoje em dia ele parece menos comum do que antes por vários motivos
Basta haver recurso ou não; não faz sentido impor limite só por impor
Jogos com 10 vezes mais largura de banda também têm dificuldade para suportar essa quantidade de inimigos
Só agora percebi que o aumento dos recursos tecnológicos talvez tenha um efeito colateral ruim sobre a eficiência e a criatividade na ciência da computação
Quanto mais largura de banda, armazenamento, memória e poder de processamento temos, mais o software reage ficando mais lento, mais inchado e mais incompetente por unidade de recurso
Dá para chamar isso de efeito Benjamin Button no design de software
Se o texto traz esse número, eu não encontrei
Mesmo entre jogos modernos, há vários multiplayer com contagens de inimigos suficientemente “massivas”, e, se o importante for número de jogadores, também existem jogos que suportam grandes quantidades de jogadores
Eu diria que foi um jogo em que passei mais tempo me movendo para trás do que indo para a frente
Está no Steam e faz parte do universo de Serious Sam, embora eu não saiba se é da mesma desenvolvedora
A estrutura de Factorio também é parecida, transmitindo quase só eventos de entrada e dependendo de um núcleo de simulação lockstep
Há algumas exceções visíveis, como a ferramenta de planejamento ferroviário
Parece uma restrição de projeto satisfatória e fácil de testar
Lembro de jogar Serious Sam em um demo da PC Gamer quando era criança
Mesmo naquela época, ele já era visto como um jogo retrô que remetia aos velhos tempos de DOOM e Quake
Agora se passaram literalmente 20 anos, e ele próprio virou um clássico
Starsiege: Tribes era grandioso e absurdamente divertido até em uma conexão 56K
https://www.gamedevs.org/uploads/tribes-networking-model.pdf
Na verdade, baixei Tribes 2 há pouco tempo e joguei contra bots alguns meses atrás
É um jogo antigo, mas ainda foi divertido, e eu penso com frequência em tentar recriá-lo com algo como Unity
Talvez um dia eu faça isso
Em 1999, ver terreno gerado proceduralmente, descer por ele como se estivesse esquiando, ver outras pessoas subirem do mesmo jeito, e ainda ter mapas grandes e muitos jogadores, era impressionante