Making SRB2Kart scripts replay-friendly

a post on tyronesama.moe

SRB2Kart 1.1 introduced the Replay Hut, which allows you to save demos of multiplayer races. This is cool. It also doesn’t store nearly enough information to guarantee that replays using Lua scripts will sync, which is less cool.

The good news: you might not have to pay super close attention to what syncs and what doesn’t. SRB2Kart replays aren’t strictly an input-playback affair; if they get slightly off sync, they’ll often quickly correct, though playback may look a little wonky.

The bad news: If you have to pay attention to replay sync, it’s gonna be bad. Here’s some stuff I learned while working on FRIENDMOD.

Don’t rely on initial state

Replays don’t save the current state of local variables, even ones that are synchronized in a NetVar hook, nor do they save information in player structs. Make sure to initialize everything you need in MapLoad or a similar place; attempting to carry state over from previous games will cause desync.

Avoid P_Random where it matters

During replay playback, calls to P_Random will almost always give you different values than the original match, since netreplays don’t include an RNG seed (and you can’t set one from scripts). Minor cosmetic randomness will play back fine, as long as you’re okay with a harmless desync message in console, but anything else can cause problems.

Gameplay-affecting randomness can often be changed to stuff that’s technically deterministic—for instance, in many cases, you can easily get something “close enough” to random using leveltime and modulo.

-- This is easy, but won't sync.
p.kartstuff[k_itemtype] = P_RandomRange(1, 14)
-- Syncs, but if players trigger it simultaneously, they get the same item...
p.kartstuff[k_itemtype] = leveltime % 14 + 1
-- ...so we can throw in a little player information to smooth that out.
p.kartstuff[k_itemtype] = (leveltime + p.skincolor) % 14 + 1

Even if players are aware of this, it’s almost impossible to manipulate, and it syncs with no extra work.

Roll your own RNG

Sometimes you can’t avoid gameplay-affecting randomness—for FRIENDMOD, random team assignment was the whole point—but what you’re randomizing is too complex for a simple hack, or happens at MapLoad where you don’t have access to much variance.

In cases like this, you might just have to implement an RNG in Lua. This is a topic of considerable scope, and there are plenty of ways to tackle it. For FRIENDMOD, I used LJ Sonik’s PRNG library with a little bit of modification. It includes some wrapper functions that allow you to find-and-replace P_Random calls easily—which was important, because I only discovered replay sync problems halfway through development.

Of course, you could also do something way simpler—the original Doom’s RNG is literally just a bigass table. The required quality of your RNG depends on the required quality of your random numbers.

Regardless of how you choose to handle this, you’ll need some way of seeding your RNG at the start of every match, to ensure that replays and gameplay produce the same random values. You can ostensibly do this in a stateless fashion, relying on the few values that are guaranteed to be present in replays, but there’s not much information available and it’s kind of a pain in the ass to deal with. Instead, you can take a “snapshot” of your RNG state with The Big Dumb Hack.

The Big Dumb Hack

If you absolutely need to carry state between games, you can write information to a cvar before the start of the next game, and the resulting replay will correctly save and read that information.

…well, you actually can’t write to cvars, because Lua puts up baby gates everywhere. There is a workaround for this, but…

local foo = CV_RegisterVar({
  name = "foo",
  defaultvalue = "420", -- We want this value...
  flags = CV_NETVAR,
  possiblevalue = CV_Unsigned
})
local bar = 69 -- To be this value. But "foo.value = bar" will error?! So instead...
COM_BufInsertText(server, "foo "..bar) -- What the fuck?

COM_BufInsertText executes commands as a given player—here, we’re using it as server to set cvars, the same way you would in your own console. It has a small delay (probably 1 game tick, I’m not testing it) and consumes part of the NetCmd buffer while it’s happening. If you try to write too much at a time, especially while the game is filling NetCmd with its own stuff, shit goes sideways and undefined behavior punches you in the head. In particular, avoid “writing” cvars during MapLoad.

Because of NetCmd restrictions, ,you should copy the values of the cvars into local variables to work with them. These values then need to be updated before the next round starts. This can be done at any time—in fact, you can even do it in a ThinkFrame—but during replay playback, it will cause a loud red error which looks really annoying. If multiple scripts do this every frame, it also presents risks regarding NetCmd, especially if gameplay is happening concurrently.

To mitigate this, you can place your cvar “writes” in a MapChange or IntermissionThinker hook. IntermissionThinker is generally a better option for this, but if the map is changed with the map command or the level select UI, it won’t run. MapChange technically happens slightly too late, so the cvar write will effectively be delayed by one game, but it’s guaranteed to happen whether the game ends normally or abnormally.

Oh, and if you change a cvar during play from the console, replays won’t reflect it. (Same goes for commands.)

Getting your initial entropy

Remember how I said never to use P_Random()? Well, there is one exception—it might be the cleanest way to “bootstrap” a custom RNG.

During normal play, advancing your RNG by “burning” numbers is enough to keep it random between games, provided you write the values back with COM_BufInsertText() before the start of the next game. This is what FRIENDMOD does.

For the first game, however, your options are limited. Like I mentioned before, you can hash player attributes and burn extra numbers based on player input, but this only makes sense in splitscreen games. On a dedicated server, your script will likely initialize with no players and no information—plus this whole thing is a pain in the ass anyway.

This isn’t cryptography—it’s a racing game for furries. Time to hack it.

local prng_seed = CV_RegisterVar({
  name = "prng_seed",
  defaultvalue = "420", -- We want a decent random number here.
  flags = CV_NETVAR,
  possiblevalue = CV_Unsigned
})
local prng_ready = CV_RegisterVar({
  name = "prng_ready",
  defaultvalue = "No", -- Always No until we set up prng_seed.
  flags = CV_NETVAR,
  possiblevalue = CV_YesNo
})

addhook("ThinkFrame", function()
  -- Before we do anything, make sure that we have good random numbers.
  if not prng_ready.value then
    -- We're using P_RandomRange here, which won't sync, but...
    COM_BufInsertText(server, "prng_seed "..P_RandomRange(0, 42069))
    -- ..that doesn't matter, since this round is about to be over.
    COM_BufInsertText(server, "prng_ready yes")
    -- And in the next round, the PRNG will be good to go.
    COM_BufInsertText(server, "map "..G_BuildMapName(0)) -- Random map.
    return
  end
  -- [...]
)

SRB2’s P_Random() seeds from system time, so it doesn’t need to be set up—it’s the highest-quality initial state we’re going to get!

This may not play nice with other mods. If you anticipate your scripts being used in a crowded addon environment, you might want to build in a toggle for the map-change behavior, so that it can be turned off if another script is also going to hijack the map at startup. I don’t know what happens if 6 scripts try to map change at the same time, but it probably starts fires.

Likewise, exposing a global function to set or save your RNG seed can help other addon developers remain compatible with your script—for instance, if they’re using unusual exit conditions that bypass intermission, they can call that function to help your script’s random values remain random.

Literally why

I don’t know and I never want to find out. The developers are aware of the shortcomings of replays, so hopefully some of them will be fixed for v2. Please don’t bug them about it, they’re already losing sleep over the netcode.