SAML: a arquitetura de um framework de futebol competitivo no Roblox
Hyperflow, a camada de replicação binária. SAC, o anti-cheat autoritativo. Física de bola própria, posse com rewind por ping e um goleiro que calcula o rebote.

Dois jogadores chegam na bola no mesmo frame. Um tem 40ms de ping, o outro tem 120ms. Cada um viu a bola num lugar diferente no instante em que apertou o botão, e os dois estão certos. Decidir de quem é a bola aí é o problema mais difícil de um jogo de futebol em rede.
Meu codinome no Roblox é Jonuffy. SAML (South American MPS League) é o framework de futebol competitivo que construo sozinho, hoje na versão 0.31. São cerca de 45 módulos entre cliente e servidor, sustentados por dois sistemas próprios que a tela de carregamento anuncia: Hyperflow, a camada de replicação, e SAC, o anti-cheat.
Os clipes ao longo do texto são da versão 0.2.2, gravados em 2 de agosto. Servem para mostrar os sistemas funcionando, não o acabamento atual: muita coisa mudou nas duas semanas até a 0.31.
De onde o gênero vem
Tudo começou em 2008 com o TPS (Tayfun Professional Soccer): cinco ferramentas, Shoot, Pass, Long, Tackle e Dribble. A comunidade reescreveu essa base e criou o MPS, um padrão aberto que qualquer um usava para montar a própria liga.
A implementação mais bem-sucedida foi o PRS, do OssieNomae, de 2015. Dez anos de iteração e três marcos que o gênero copiou depois: a reforma de interface de 2020, a reescrita das ferramentas no mesmo ano, e o hub de servidor único de 2021, que acabou com a necessidade de duplicar places para abrir partida.
Hoje o Ossie está reescrevendo o PRS do zero sob o codinome Project Vulpes, com foco em quem joga: cooldown cancelável no meio da ação, física que não empurra o goleiro para dentro do próprio gol, e um anti-lag que detecta stutter de rede no servidor e usa isso na arbitragem.
O SAML partiu de outro ponto. Em vez de reformar, comecei pela fundação e deixei as features virem depois.
Hyperflow: a camada de replicação
O padrão comum em Roblox é um RemoteEvent por tipo de mensagem. Vinte sistemas, vinte remotes, e o custo de disparo se multiplica a cada frame. Hyperflow é o nome que dei ao que substituiu isso: um canal binário, replicação de animação procedural e controle de alcance, os três operando juntos.
Serialização. O jogo inteiro passa por dois remotes, um confiável e um não confiável, separados por canal em vez de por instância. Os dados vão num buffer binário em formato TLV: número vira f64, Vector3 e CFrame viram f32, Color3 vira três bytes, e Instance viaja por índice numa lista paralela, já que referência não cabe em buffer.
Batching. Nenhuma chamada envia nada na hora, ela empilha numa fila. Um único listener em RunService.PostSimulation serializa tudo que acumulou no frame e dispara de uma vez por jogador, mesclando a fila de broadcast em vez de reserializar por destinatário. Numa disputa com dez jogadores em campo, um frame gera efeito visual, weld de perna, marcador tático e atualização de posse: sem batching, são quatro disparos por jogador por frame; com batching, é um.
Animação procedural replicada. Essa é a limitação da engine que quase ninguém resolve. Animação feita por CFrame em Weld, em vez de AnimationTrack, só é visível para quem controla o personagem. Os outros veem um boneco parado chutando. O Rig serializa cada movimento de weld em campos compactos, junta os do frame num task.defer e manda pelo canal binário; o servidor aceita até 8 por chamada e retransmite só para a audiência relevante, que é quem está perto mais espectadores e holofote. Um sweep paralelo destrói weld obsoleto e devolve o Motor6D original, o que impede a perna de ficar grudada numa pose antiga quando um movimento é cortado no meio.
Alcance. Toda interação com a bola passa por validação de distância no servidor, então não existe pegar de longe. É a mesma checagem que sustenta a arbitragem de posse mais adiante.
Movimento é dado, não código
O StarterPack roda dentro das Tool de cada jogador e se divide em quatro módulos: Mechanics guarda o estado, Rig cuida do corpo físico, Runners são os motores de movimento e Controls liga input e HUD.
Cada movimento do Playbook declara um kind, e o kind escolhe o runner que executa. São 18 tipos, agrupados por natureza:
| Grupo | Tipos | O que resolvem |
|---|---|---|
| Toque | kick, double, multi |
Contato direto, uma ou várias partes do corpo |
| Carga | wind, animWind, charge, throw |
Barra de força, cancelamento, pose escolhida pelo combo |
| Animação | anim, animStrike, animHold, sequence |
Timing vindo de keyframe em vez de task.delay |
| Goleiro | dive, catch, punch, ballAction |
Mergulho com freio no ar, defesa, soco, bola presa |
| Corpo | acrobatic, slide |
Bicicleta, rabona aérea, carrinho |
| Tático | marker |
Chamada desenhada em campo, clique contra arraste |
O que os movimentos não declaram é tempo. Cooldown e duração entram depois, injetados por categoria:
local moves = {
Bicycle = "strike", Rabona = "strike", Volley = "strike",
Shoot = "signature", Pass = "signature", Long = "signature",
ChopRight = "dribble", Rainbow = "dribble",
}
function balance.apply(playbook)
for _, moveset in playbook do
for name, def in moveset do
local class = classes[moves[name] or "light"]
def.cooldown = timings[name] or class.cooldown
def.duration = class.duration
def.frequency = class.frequency
end
end
endSão oito categorias. Rebalancear a família inteira de dribles é mudar uma linha. E como frequency alimenta o JointSpring, a categoria de um movimento define até a rigidez da mola que anima a perna.
A disputa pela bola
Evento .Touched chega tarde demais para bola em alta velocidade: ela atravessa o volume do pé entre dois passos de simulação e o evento nunca dispara. Enquanto existe um impacto armado, uma varredura ativa roda a cada Heartbeat por cima do .Touched, e quem arma o impacto é o próprio movimento. Um chute de direita arma só o pé direito. Um Header arma só a cabeça.
Proteger a bola com o corpo funciona, e o mecanismo é raycast. Um leque de 15 raios, cinco alturas por três posições laterais, sai do atacante em direção à bola filtrando só o corpo do dono atual. Se qualquer raio acerta esse corpo antes de chegar na bola, a disputa é negada. Goleiro ignora a blindagem.
Se cada toque esperasse resposta do servidor, o jogo teria a latência do jogador embutida em todo chute. Ao se aproximar de uma bola livre, o cliente reserva a posse por uma janela de 0.5s e aplica a força na hora, localmente, antes de qualquer confirmação. Se o servidor discordar depois, ele corrige.
Do outro lado, o servidor mantém um histórico de posições da bola amostrado a 30Hz e rebobina até o instante em que aquele cliente enxergava a bola:
local function grant(player, ball, clientPos)
local seen = rewind(ball, player:GetNetworkPing())
local drift = (seen - clientPos).Magnitude
if drift > REACH * 3 then
warn(`[Handler] drift {drift} de {player.Name}`)
return false
end
return drift <= REACH
endOs dois limiares fazem coisas diferentes. REACH decide a jogada. REACH * 3 gera um aviso no log, porque desvio dessa magnitude não é latência: é cliente reportando posição que a bola nunca ocupou. É aí que a arbitragem de posse encosta no anti-cheat.
SAC: o anti-cheat
A premissa do SAC é que o cliente nunca tem a palavra final. Ele roda em quatro frentes.
Sanitização de entrada. Todo handler de rede no servidor passa pelo guard antes de confiar em qualquer coisa: guard.number, guard.vector, guard.cframe e guard.text conferem tipo e limite, e guard.victim valida um alvo de interação checando se o humanoid está vivo e dentro do alcance. Vetor com NaN, CFrame absurdo ou alvo do outro lado do campo morrem antes de virar estado de jogo.
Limite de taxa. guard.allow é um token bucket por jogador e por canal. Cada superfície tem custo próprio, o que importa porque as ações não custam a mesma coisa: arrastar um objeto no editor dispara dezenas de chamadas por segundo e precisa ser barato, enquanto salvar em DataStore precisa ser caro.
Telemetria de remotos. O cliente varre ReplicatedStorage.Events contando disparos de cada RemoteEvent e reporta a cada 5 segundos. A contagem vem do cliente, então é indício e não prova, e por isso ela alimenta o painel de moderação em vez de aplicar punição sozinha. Padrão anômalo de disparo aparece antes de virar reclamação de jogador.
Autoridade de física. Posse só é concedida pelo servidor, e todo desvio acima do triplo do alcance vira aviso instrumentado. Em cima disso roda uma rede de segurança que trava a velocidade vertical do personagem em 200 studs/s, o que fecha a categoria de exploit que usa glitch de física para se lançar para fora do mapa.
A hierarquia administrativa segue a mesma lógica: seis níveis de poder, e todo comando sensível compara o poder de quem executa com o do alvo antes de rodar, então admin júnior não afeta admin sênior.
O goleiro
A peça de matemática mais bonita do projeto está no rebote. Quando a bola bate no goleiro, a saída não é animação fixa: é reflexão especular contra a normal de contato, somada a uma fração da velocidade do próprio goleiro.
local function parryVector(context, ball, bonus)
local incoming = ball.AssemblyLinearVelocity
local normal = (ball.Position - context.contact).Unit
local reflected = (incoming - 2 * incoming:Dot(normal) * normal).Unit
if reflected:Dot(context.goalOutward) < 0.5 then
local lateral = reflected:Cross(Vector3.yAxis).Unit
reflected = (context.goalOutward * 0.7 + lateral * 0.85).Unit
end
local speed = incoming.Magnitude * 0.62 + context.keeperSpeed * 0.3 + bonus
return reflected * math.clamp(speed, 20, 50)
endAquele 0.5 é o guarda-corpo. Abaixo dele o vetor está apontando perigosamente para o gol, e o cálculo recompõe misturando componente para fora com componente lateral. Sem isso, defesa em ângulo fechado joga a bola para dentro, que é a reclamação clássica de goleiro em jogo de futebol no Roblox.
Os mergulhos são tabelas por direção, e cada um tem uma brakeKey opcional: apertar a tecla oposta no meio do voo cancela o mergulho e devolve controle. Mergulho deixa de ser um compromisso cego.
Treino que você monta
O modo de treino não é um campo com cones. É um editor onde você monta a própria rotina, posicionando peças que agem sozinhas com intervalo, força e direção configuráveis.
| O que você posiciona | O que treina |
|---|---|
| Auto-passe, com intervalo configurável | Domínio, primeiro toque, giro de corpo |
| Auto-chute, com força e ângulo definidos | Reflexo e posicionamento de goleiro |
| Auto-cruzamento, disparado ao toque | Cabeceio, voleio, bicicleta |
| Barreira | Falta com curva por cima ou por fora |
| Obstáculo | Condução em espaço apertado |
Cada peça vai ao campo com gizmos 3D nativos, com Alt para ignorar o snap de grade e seleção múltipla para mover conjuntos. O posicionamento resolve colisão sozinho procurando em espiral, 12 direções por 14 anéis, até achar espaço livre. Há histórico de 25 estados para desfazer e refazer, e a rotina inteira salva em um dos 6 slots persistidos por jogador, então o treino que você montou continua lá na próxima sessão.
O lançador tem teto de 12 bolas vivas ao mesmo tempo, e todo o editor passa pelo token bucket do SAC, com Move custando pouco por ser arraste contínuo e SaveSlot custando caro por tocar DataStore.
Replay e arbitragem
O replay grava o tempo todo: ring buffer de 32 segundos a 15Hz, capturando seis partes por jogador mais as bolas. Todo Sound do jogo é interceptado e tem timestamp gravado, então o chute do replay soa como o chute original. A montagem clona os personagens num palco isolado, remove scripts e constraints dos clones, e esconde o mundo real por trás continuamente para não vazar a partida ao vivo. Cabem 14 clipes congelados e um cache de 24 rigs de jogadores que já saíram, então um replay de gol continua mostrando o autor depois que ele desconectou.
A arbitragem tem geometria de verdade por trás. isShotAttempt faz projeção balística considerando gravidade para separar chute a gol de passe. O impedimento monta snapshot a cada toque legítimo, com distância mínima do passador para não marcar em jogada colada. A disciplina escala sozinha: cartão a cada três faltas, antecipado se a falta foi grave ou se o jogador já cometeu duas nos últimos 240 segundos, e vermelho direto quando a falta nega chance clara de gol.
Quem decide depende do modo. Full aplica direto. Semi manda o lance para a mesa e espera até 8 segundos, caindo para automático se não houver ninguém lá. Manual nunca decide sozinho. Em cima disso roda revisão estilo VAR, com aprovação dos Officials ou votação pública.
O que sustenta tudo isso
O save por jogador usa locking otimista com dono, carimbo e TTL de 120 segundos, em três tentativas com backoff. Sem isso, um jogador que troca de servidor rápido tem o perfil sobrescrito pelo servidor antigo salvando depois do novo.
As configurações resolvem migração sem versionar nada: em vez de carregar número de versão, o sistema detecta valor salvo fora dos limites atuais do schema e reseta ao default. Se um slider ia de 0 a 200 e virou 0 a 100 num patch, todo save antigo se corrige sozinho.
Antes de cada release maior eu passo o servidor inteiro numa revisão dedicada, procurando sempre as mesmas quatro classes de problema:
| Classe | O que procurar | Como fica |
|---|---|---|
| Idempotência | Compra concedida sem registrar o PurchaseId |
DataStore de recibos processados, checado antes de conceder |
| Autorização | Comando que não compara o poder de quem executa com o do alvo | Hierarquia estrita em todo comando sensível |
| Posse | Remoto que aceita a palavra do cliente sobre de quem é a bola | Mesma validação de alcance que o resto do sistema já usa |
| Taxa | Superfície sem rate limit, principalmente as que tocam DataStore | Token bucket com custo por ação |
A primeira é a que mais importa, porque envolve Robux de verdade. A Roblox reenvia recibo quando o processamento dá timeout, e handler que trata cada notificação como evento único concede duas vezes. A correção é a mesma de qualquer sistema de pagamento assíncrono: chave de idempotência persistida, consultada antes de entregar qualquer coisa.
SAML 0.31
| Sistema | Números |
|---|---|
| Módulos | ~45 (20 client, 12 server, 8 base, 4 StarterPack) |
| RemoteEvents no caminho principal | 2 |
| Tipos de runner | 18, em 8 categorias de balanceamento |
| Ferramentas | Shoot, Pass, Long, Tackle, Dribble, Clear, TI |
| Replay | 32s a 15Hz, 14 clipes arquivados |
| Amostragem de posse | 30Hz, com rewind por ping |
| Treino | 6 slots salvos, 25 estados de histórico |
| Catálogo | 53 comemorações, 44 seleções, 92 configurações |
Para jogar: SAML New Tools. O arquivo .rbxl está em download direto.
O padrão
Olhando os módulos de longe, quase todos resolvem a mesma frase: a engine diz não, então você escreve o seu.
.Touched chega tarde para bola rápida, então a detecção de impacto varre volume por conta própria. Animação procedural não replica, então o Hyperflow transforma o weld em bytes e devolve como movimento na tela dos outros. O network ownership entrega a bola a quem tem menos ping, então o servidor rebobina até o instante que o cliente viu, e o SAC trata o que sobra de desvio como sinal, não como ruído.
Nenhuma dessas decisões aparece na tela. Todas elas são a diferença entre um chute que responde e um chute que parece atrasado.
Continue lendo
- 14 min
jStudio: aplicativo de desktop em Tauri e Rust para o Roblox Studio
Ponte local com o plugin do Studio, agente com onze ferramentas e revisão obrigatória, cliente MCP embutido e um pipeline que reenvia animação, áudio, imagem e mesh pela Open Cloud. - 9 min
Paycel: modelagem de despesa recorrente, tempo real e a disciplina por trás
Como uma conta que se repete todo mês é guardada uma vez só, por que o salário nunca vai ao servidor, e os critérios de versionamento e de modelagem de dados que aplico em todo projeto. - 15 min
Minha evolução: de scripts em Lua a aplicativos de desktop em Rust
O caminho de 2017 até hoje, tecnologia por tecnologia e decisão por decisão: HTML, EJS, Next.js, Electron, Tauri, Capacitor, os bancos que uso e por quê, e o papel exato que a IA ocupa no meu trabalho.