Contexto
Roblox fornece um conjunto de APIs para interagir com armazenamentos de dados via DataStoreService. O caso de uso mais comum para essas APIs é salvar, carregar e replicar dados do jogador. Ou seja, dados associados ao progresso do jogador, compras e outras características da sessão que persistem entre sessões de jogo individuais.
A maioria dos jogos no Roblox usa essas APIs para implementar algum tipo de sistema de dados do jogador. Essas implementações diferem em sua abordagem, mas geralmente buscam resolver o mesmo conjunto de questões.
Problemas comuns
Abaixo estão alguns dos problemas mais comuns que os sistemas de dados do jogador tentam resolver:
Acesso em Memória: As solicitações de DataStoreService fazem requisições web que operam de forma assíncrona e estão sujeitas a limites de taxa. Isso é apropriado para uma carga inicial no início da sessão, mas não para operações de leitura e escrita de alta frequência durante o curso normal do jogo. A maioria dos sistemas de dados do jogador dos desenvolvedores armazena esses dados em memória no servidor Roblox, limitando as solicitações de DataStoreService aos seguintes cenários:
- Leitura inicial no início de uma sessão
- Escrita final no final da sessão
- Escritas periódicas em intervalos para mitigar o cenário em que a escrita final falha
- Escritas para garantir que os dados sejam salvos enquanto processa uma compra
Armazenamento Eficiente: Armazenar todos os dados da sessão de um jogador em uma única tabela permite que você atualize múltiplos valores de forma atômica e lide com a mesma quantidade de dados em menos requisições. Também elimina o risco de desincronização entre valores e torna os retrocessos mais fáceis de lidar.
Alguns desenvolvedores também implementam serialização personalizada para comprimir grandes estruturas de dados (geralmente para salvar conteúdo gerado pelo usuário no jogo).
Replicação: O cliente precisa de acesso regular aos dados de um jogador (por exemplo, para atualizar a UI). Uma abordagem genérica para replicar dados do jogador para o cliente permite que você transmita essas informações sem precisar criar sistemas de replicação personalizados para cada componente de dados. Os desenvolvedores frequentemente querem a opção de ser seletivos sobre o que é e o que não é replicado para o cliente.
Tratamento de Erros: Quando os DataStores não podem ser acessados, a maioria das soluções implementará um mecanismo de repetição e um fallback para dados 'padrão'. Cuidado especial é necessário para garantir que os dados de fallback não substituam posteriormente os dados 'reais', e que isso seja comunicado ao jogador adequadamente.
Repetições: Quando os DataStores estão inacessíveis, a maioria das soluções implementa um mecanismo de repetição e um fallback para dados padrão. Tenha cuidado especial para garantir que os dados de fallback não substituam posteriormente os dados "reais" e comunique a situação ao jogador adequadamente.
Bloqueio de Sessão: Se os dados de um único jogador estão carregados e em memória em vários servidores, podem ocorrer problemas em que um servidor salva informações desatualizadas. Isso pode levar à perda de dados e a brechas comuns de duplicação de itens.
Manipulação Atômica de Compras: Verifique, conceda e registre compras de forma atômica para evitar que itens sejam perdidos ou concedidos várias vezes.
Código de exemplo
Roblox possui código de referência para ajudá-lo a projetar e construir sistemas de dados do jogador. O restante desta página examina o contexto, detalhes de implementação e advertências gerais.
Depois de importar o modelo para o Studio, você deve ver a seguinte estrutura de pastas:

Arquitetura
Este diagrama de alto nível ilustra os principais sistemas no exemplo e como eles interagem com o código no resto do jogo.

Repetições
Classe: DataStoreWrapper
Contexto
Como DataStoreService faz requisições web por baixo dos panos, suas solicitações não são garantidas para ter sucesso. Quando isso acontece, os métodos DataStore lançam erros, permitindo que você os trate.
Um "problema" comum pode ocorrer se você tentar tratar falhas no DataStore assim:
local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end
Embora este seja um mecanismo de repetição perfeitamente válido para uma função genérica, ele não é adequado para solicitações de DataStoreService porque não garante a ordem em que as solicitações são feitas. Preservar a ordem das solicitações é importante para as solicitações de DataStoreService, pois interagem com o estado. Considere o seguinte cenário:
- A solicitação A é feita para definir o valor da chave K para 1.
- A solicitação falha, então uma repetição é agendada para ser executada em 2 segundos.
- Antes que ocorra a repetição, a solicitação B define o valor de K para 2, mas a repetição da solicitação A imediatamente sobrescreve esse valor e define K para 1.
Mesmo que UpdateAsync opere na versão mais recente do valor da chave, as solicitações de UpdateAsync ainda precisam ser processadas em ordem para evitar estados transitórios inválidos (por exemplo, uma compra subtrai moedas antes que uma adição de moedas seja processada, resultando em moedas negativas).
Nosso sistema de dados do jogador usa uma nova classe, DataStoreWrapper, que fornece repetições que garantem ser processadas em ordem por chave.
Abordagem

DataStoreWrapper fornece métodos correspondentes aos métodos DataStore: DataStore:GetAsync(), DataStore:SetAsync(), DataStore:UpdateAsync() e DataStore:RemoveAsync().
Esses métodos, quando chamados:
Adicionam a solicitação a uma fila. Cada chave tem sua própria fila, onde as solicitações são processadas em ordem e em série. O thread que solicita cede até que a solicitação seja concluída.
Essa funcionalidade é baseada na classe ThreadQueue, que é um agendador de tarefas baseado em corrotinas e limitador de taxa. Em vez de retornar uma promessa, ThreadQueue cede o thread atual até que a operação seja concluída e lança um erro se falhar. Isso é mais consistente com padrões assíncronos idiomáticos de Luau.
Se uma solicitação falhar, ela é repetida com um backoff exponencial configurável. Essas repetições fazem parte do callback enviado ao ThreadQueue, então elas são garantidas para serem concluídas antes que a próxima solicitação na fila para esta chave comece.
Quando uma solicitação é concluída, o método de solicitação retorna com o padrão sucesso, resultado.
DataStoreWrapper também expõe métodos para obter o comprimento da fila para uma determinada chave e limpar solicitações obsoletas. Esta última opção é particularmente útil em cenários em que o servidor está desligando e não há tempo para processar nenhuma solicitação, exceto as mais recentes.
Advertências
DataStoreWrapper segue o princípio de que, fora de cenários extremos, cada solicitação de DataStore deve ser permitida a ser concluída (com sucesso ou não), mesmo que uma solicitação mais recente a torne redundante. Quando uma nova solicitação ocorre, as solicitações obsoletas não são removidas da fila, mas são permitidas a serem concluídas antes que a nova solicitação seja iniciada. A justificativa para isso está enraizada na aplicabilidade deste módulo como uma utilidade genérica de DataStore, em vez de uma ferramenta específica para dados de jogador, e é a seguinte:
É difícil decidir um conjunto intuitivo de regras para quando uma solicitação é segura para ser removida da fila. Considere a seguinte fila:
Valor=0, SetAsync(1), GetAsync(), SetAsync(2)
O comportamento esperado é que GetAsync() retornaria 1, mas se removermos a solicitação SetAsync() da fila devido a ela ser tornada redundante pela mais recente, retornaria 0.
A progressão lógica é que, quando uma nova solicitação de escrita é adicionada, apenas as solicitações obsoletas são podadas até a solicitação de leitura mais recente. UpdateAsync, de longe, a operação mais comum (e a única usada por este sistema), pode tanto ler quanto escrever, então seria difícil conciliar isso dentro desse design sem adicionar complexidade extra.
DataStoreWrapper poderia exigir que você especificasse se uma solicitação UpdateAsync() estava autorizada a ler e/ou escrever, mas isso não teria aplicabilidade ao nosso sistema de dados do jogador, onde isso não pode ser determinado com antecedência devido ao mecanismo de bloqueio de sessão (coberto em mais detalhes a seguir).
Uma vez removida da fila, é difícil decidir uma regra intuitiva para como isso deve ser tratado. Quando uma solicitação DataStoreWrapper é feita, o thread atual é cedido até que seja concluído. Se removêssemos solicitações obsoletas da fila, teríamos que decidir se devemos retornar false, "Removido da fila" ou nunca retornar e descartar o thread ativo. Ambas as abordagens têm suas próprias desvantagens e descarregam complexidade adicional ao consumidor.
Em última análise, nossa visão é a de que a abordagem simples (processar cada solicitação) é preferível aqui e cria um ambiente mais claro para navegar em questões complexas, como bloqueio de sessão. A única exceção a isso é durante DataModel:BindToClose(), onde limpar a fila se torna necessário para salvar os dados de todos os usuários a tempo e o valor que chamadas de função individuais retornam não é mais uma preocupação contínua. Para um contexto mais amplo, veja Dados do Jogador.
Bloqueio de Sessão
Classe: SessionLockedDataStoreWrapper
Contexto
Os dados do jogador são armazenados em memória no servidor e só são lidos e escritos nos DataStores subjacentes quando necessário. Você pode ler e atualizar os dados do jogador em memória instantaneamente sem precisar de requisições web e evitar exceder os limites de DataStoreService.
Para que este modelo funcione conforme pretendido, é imperativo que não mais de um servidor consiga carregar os dados de um jogador na memória do DataStore ao mesmo tempo.
Por exemplo, se o servidor A carrega os dados de um jogador, o servidor B não pode carregar esses dados até que o servidor A libere seu bloqueio durante uma salva final. Sem um mecanismo de bloqueio, o servidor B poderia carregar dados desatualizados do jogador do DataStore antes que o servidor A tivesse a chance de salvar a versão mais recente que tem em memória. Então, se o servidor A salvar seus dados mais novos depois que o servidor B carregar os dados desatualizados, o servidor B sobrescreveria esses dados mais novos durante sua próxima salva.
Mesmo que o Roblox só permita que um cliente esteja conectado a um servidor por vez, você não pode assumir que os dados de uma sessão sejam sempre salvos antes que a próxima sessão comece. Considere os seguintes cenários que podem ocorrer quando um jogador sai do servidor A:
- O servidor A faz uma solicitação de DataStore para salvar seus dados, mas a requisição falha e precisa de várias repetições para ser concluída com sucesso. Durante o período de repetição, o jogador entra no servidor B.
- O servidor A faz muitas chamadas UpdateAsync() para a mesma chave e é restringido. A última solicitação de salvar é colocada em uma fila. Enquanto a solicitação está na fila, o jogador entra no servidor B.
- No servidor A, algum código conectado ao evento PlayerRemoving se cede antes que os dados do jogador sejam salvos. Antes que essa operação seja concluída, o jogador entra no servidor B.
- O desempenho do servidor A se degradou a tal ponto que a última salva é atrasada até depois que o jogador entra no servidor B.
Esses cenários devem ser raros, mas ocorrem, particularmente em situações onde um jogador desconecta de um servidor e se conecta a outro em rápida sucessão (por exemplo, enquanto está se teleportando). Algum usuário malicioso pode até tentar abusar desse comportamento para completar ações sem que elas persistam. Isso pode ser particularmente impactante em jogos que permitem que jogadores troquem e é uma fonte comum de exploits de duplicação de itens.
O bloqueio de sessão aborda essa vulnerabilidade garantindo que, quando a chave do DataStore de um jogador é lida pela primeira vez pelo servidor, o servidor escreve atômicamente um bloqueio nos metadados da chave dentro da mesma chamada de UpdateAsync(). Se esse valor de bloqueio estiver presente quando qualquer outro servidor tentar ler ou escrever a chave, o servidor não prossegue.
Abordagem

SessionLockedDataStoreWrapper é um meta-wrapper em torno da classe DataStoreWrapper. DataStoreWrapper fornece funcionalidade de fila e repetição, que SessionLockedDataStoreWrapper complementa com bloqueio de sessão.
SessionLockedDataStoreWrapper passa cada solicitação de DataStore—independente de ser GetAsync, SetAsync ou UpdateAsync—através de UpdateAsync. Isso ocorre porque UpdateAsync permite que uma chave seja lida e escrita atômica e simultaneamente. Também é possível abandonar a escrita com base no valor lido retornando nil no callback de transformação.
A função de transformação passada para UpdateAsync para cada solicitação realiza as seguintes operações:
Verifica se a chave é segura para acesso, abandonando a operação se não for. "Seguro para acesso" significa:
O objeto de metadados da chave não inclui um valor LockId não reconhecido que foi atualizado pela última vez menos de um tempo de expiração de bloqueio atrás. Isso serve para respeitar um bloqueio colocado por outro servidor e para ignorar esse bloqueio se expirou.
Se este servidor colocou seu próprio valor LockId nos metadados da chave anteriormente, então esse valor ainda está nos metadados da chave. Isso considera a situação em que um outro servidor assumiu o bloqueio deste servidor (por expiração ou à força) e depois o liberou. Alternativamente, mesmo que LockId seja nil, outro servidor poderia ter substituído e removido um bloqueio no tempo desde que você bloqueou a chave.
UpdateAsync executa a operação DataStore que o consumidor de SessionLockedDataStoreWrapper solicitou. Por exemplo, GetAsync() traduz-se para function(value) return value end.
Dependendo dos parâmetros passados na solicitação, UpdateAsync bloqueia ou desbloqueia a chave:
Se a chave deve ser bloqueada, UpdateAsync define o LockId nos metadados da chave para um GUID. Esse GUID é armazenado em memória no servidor para que possa ser verificado da próxima vez que acessar a chave. Se o servidor já tiver um bloqueio nesta chave, não faz alterações. Também agenda uma tarefa para avisá-lo se você não acessar a chave novamente para manter o bloqueio dentro do tempo de expiração do bloqueio.
Se a chave deve ser desbloqueada, UpdateAsync remove o LockId dos metadados da chave.
Um manipulador de repetição personalizado é passado para o DataStoreWrapper subjacente para que a operação seja repetida se for abortada na etapa 1 devido ao bloqueio da sessão.
Uma mensagem de erro personalizada também é retornada ao consumidor, permitindo que o sistema de dados do jogador relate um erro alternativo no caso de bloqueio da sessão ao cliente.
Advertências
O regime de bloqueio de sessão depende de que um servidor sempre libere seu bloqueio em uma chave quando terminar com ele. Isso deve sempre acontecer por meio de uma instrução para desbloquear a chave como parte da escrita final no PlayerRemoving ou BindToClose().
No entanto, o desbloqueio pode falhar em certas situações. Por exemplo:
- O servidor falhou ou DataStoreService estava inoperável para todas as tentativas de acessar a chave.
- Devido a um erro na lógica ou bug semelhante, a instrução para desbloquear a chave não foi feita.
Para manter o bloqueio em uma chave, você deve acessá-la regularmente enquanto estiver carregada em memória. Isso normalmente seria feito como parte do loop de salvamento automático que roda em segundo plano na maioria dos sistemas de dados do jogador, mas este sistema também expõe um método refreshLockAsync caso você precise fazer isso manualmente.
Se o tempo de expiração do bloqueio foi excedido sem que o bloqueio tenha sido atualizado, então qualquer servidor é livre para assumir o bloqueio. Se um servidor diferente assumir o bloqueio, as tentativas do servidor atual de ler ou escrever a chave falham, a menos que estabeleça um novo bloqueio.
Processamento de produtos do desenvolvedor
Singleton: ReceiptHandler
Contexto
O callback ProcessReceipt desempenha o papel crítico de determinar quando finalizar uma compra. ProcessReceipt é chamado em cenários muito específicos. Para seu conjunto de garantias, veja MarketplaceService.ProcessReceipt.
Embora a definição de "manipulação" de uma compra possa diferir entre jogos, usamos os seguintes critérios:
A compra não foi manipulada anteriormente.
A compra é refletida na sessão atual.
Isso requer realizar as seguintes operações antes de retornar PurchaseGranted:
- Verificar se o PurchaseId não foi registrado como manipulado.
- Conceder a compra nos dados de jogador em memória.
- Registrar o PurchaseId como manipulado nos dados de jogador em memória.
- Escrever os dados de jogador em memória para o DataStore.
O bloqueio de sessão simplifica esse fluxo, pois você não precisa mais se preocupar com os seguintes cenários:
- Os dados do jogador em memória no servidor atual potencialmente estarem desatualizados, exigindo que você busque o valor mais recente no DataStore antes de verificar o histórico de PurchaseId.
- O callback para a mesma compra rodando em outro servidor, exigindo que você tanto leia quanto escreva o histórico de PurchaseId e salve os dados de jogador atualizados com a compra refletida atômica para prevenir condições de corrida.
O bloqueio de sessão garante que, se uma tentativa de gravar no DataStore do jogador for bem-sucedida, nenhum outro servidor leu ou escreveu com sucesso no DataStore do jogador entre os dados sendo carregados e salvos neste servidor. Em resumo, os dados do jogador em memória neste servidor são a versão mais atualizada disponível. Existem algumas advertências, mas elas não impactam esse comportamento.
Abordagem
Os comentários em ReceiptProcessor delineiam a abordagem:
Verifique se os dados do jogador estão atualmente carregados neste servidor e que foram carregados sem erros.
Como este sistema usa bloqueio de sessão, essa verificação também confirma que os dados em memória são a versão mais atualizada.
Se os dados do jogador não foram carregados ainda (o que é esperado quando um jogador entra em um jogo), aguarde que os dados do jogador sejam carregados. O sistema também escuta quando o jogador sai do jogo antes que seus dados sejam carregados, pois não deve ceder indefinidamente e bloquear esse callback de ser invocado novamente neste servidor para essa compra se o jogador retornar.
Verifique se o PurchaseId não está registrado como processado nos dados do jogador.
Devido ao bloqueio de sessão, a matriz de PurchaseIds que o sistema tem em memória é a versão mais atualizada. Se o PurchaseId é registrado como processado e refletido em um valor que foi carregado ou salvo no DataStore, retorne PurchaseGranted. Se está registrado como processado, mas não refletido no DataStore, retorne NotProcessedYet.
Atualize os Dados do Jogador localmente neste servidor para "conceder" a compra.
ReceiptProcessor adota uma abordagem genérica de callback e atribui um callback diferente para cada DeveloperProductId.
Atualize os dados do jogador localmente neste servidor para armazenar o PurchaseId.
Envie uma solicitação para salvar os dados em memória no DataStore, retornando PurchaseGranted se a solicitação for bem-sucedida. Caso contrário, retorne NotProcessedYet.
Se esta solicitação de salvamento não for bem-sucedida, uma solicitação posterior para salvar os dados da sessão em memória do jogador ainda pode ter sucesso. Durante a próxima chamada de ProcessReceipt, a etapa 2 lida com essa situação e retorna PurchaseGranted.
Dados do jogador
Singletons: PlayerData.Server, PlayerData.Client
Contexto
Módulos que fornecem uma interface para código ler e escrever dados da sessão do jogador de forma síncrona são comuns em jogos Roblox. Esta seção aborda PlayerData.Server e PlayerData.Client.
Abordagem
PlayerData.Server e PlayerData.Client lidam com o seguinte:
- Carregar os dados do jogador em memória, incluindo lidar com casos em que falha ao carregar
- Fornecer uma interface para que o código do servidor consulte e altere os dados do jogador
- Replicar alterações nos dados do jogador para o cliente para que o código do cliente possa acessá-los
- Replicar erros de carregamento e/ou salvamento para o cliente para que ele possa mostrar diálogos de erro
- Salvar os dados do jogador periodicamente, quando o jogador sai e quando o servidor é desligado
Carregar dados do jogador

SessionLockedDataStoreWrapper faz uma solicitação getAsync para o DataStore.
Se esta solicitação falhar, os dados padrão são usados e o perfil é marcado como "com erro" para garantir que não seja escrito no DataStore posteriormente.
Uma opção alternativa é expulsar o jogador, mas recomendamos deixar o jogador jogar com dados padrão e uma mensagem clara sobre o que ocorreu em vez de removê-lo do jogo.
Um payload inicial é enviado para PlayerDataClient contendo os dados carregados e o status de erro (se houver).
Quaisquer threads cedidas usando waitForDataLoadAsync para o jogador são retomadas.
Fornecer uma interface para o código do servidor
- PlayerDataServer é um singleton que pode ser requerido e acessado por qualquer código do servidor que esteja rodando no mesmo ambiente.
- Os dados do jogador são organizados em um dicionário de chaves e valores. Você pode manipular esses valores no servidor usando os métodos setValue, getValue, updateValue e removeValue. Todos esses métodos operam de forma síncrona sem ceder.
- Os métodos hasLoaded e waitForDataLoadAsync estão disponíveis para garantir que os dados tenham sido carregados antes de você acessá-los. Recomendamos fazer isso uma vez durante uma tela de carregamento antes que outros sistemas comecem para evitar ter que verificar erros de carga antes de cada interação com os dados no cliente.
- Um método hasErrored pode consultar se a carga inicial do jogador falhou, fazendo com que ele use dados padrão. Verifique esse método antes de permitir que o jogador faça quaisquer compras, pois compras não podem ser salvas nos dados sem um carregamento bem-sucedido.
- Um sinal playerDataUpdated é acionado com o jogador, chave e valor sempre que os dados de um jogador são alterados. Sistemas individuais podem se inscrever para isso.
Replicar alterações para o cliente
- Qualquer alteração nos dados do jogador em PlayerDataServer é replicada para PlayerDataClient, a menos que essa chave tenha sido marcada como privada usando setValueAsPrivate
- setValueAsPrivate é usado para denotar chaves que não devem ser enviadas para o cliente
- PlayerDataClient inclui um método para obter o valor de uma chave (get) e um sinal que é acionado quando ela é atualizada (atualizado). Um método hasLoaded e um sinal loaded também estão incluídos, para que o cliente possa aguardar os dados carregarem e replicarem antes de iniciar seus sistemas
- PlayerDataClient é um singleton que pode ser requerido e acessado por qualquer código do cliente que esteja sendo executado no mesmo ambiente
Replicar erros para o cliente
- Os status de erro encontrados ao salvar ou carregar dados do jogador são replicados para PlayerDataClient.
- Acesse essas informações pelos métodos getLoadError e getSaveError, junto com os sinais loaded e saved.
- Existem dois tipos de erros: DataStoreError (a solicitação DataStoreService falhou) e SessionLocked (veja Bloqueio de Sessão).
- Use esses eventos para desabilitar prompts de compra do cliente e implementar diálogos de aviso. Esta imagem mostra um exemplo de diálogo:

Salvar dados do jogador

Quando o jogador sai do jogo, o sistema toma as seguintes medidas:
- Verifica se é seguro escrever os dados do jogador no DataStore. Cenários em que não seria seguro incluem a falha do carregamento dos dados do jogador ou ainda estar em processo de carregamento.
- Faz uma solicitação através de SessionLockedDataStoreWrapper para gravar o valor atual em memória no DataStore e remover o bloqueio da sessão uma vez completo.
- Limpa os dados do jogador (e outras variáveis como metadados e statuses de erro) da memória do servidor.
Em um loop periódico, o servidor grava os dados de cada jogador no DataStore (desde que seja seguro salvar). Essa redundância bem-vinda mitiga perdas em caso de uma falha do servidor e também é necessária para manter o bloqueio da sessão.
Quando uma solicitação para desligar o servidor é recebida, o seguinte ocorre em um callback BindToClose:
- Uma solicitação é feita para salvar os dados de cada jogador no servidor, seguindo o processo normalmente realizado quando um jogador sai do servidor. Essas solicitações são feitas em paralelo, já que os callbacks de BindToClose têm apenas 30 segundos para serem concluídos.
- Para agilizar os salvamentos, todas as outras solicitações na fila de cada chave são limpas do DataStoreWrapper subjacente (veja Repetições).
- O callback não retorna até que todas as solicitações tenham sido concluídas.