I had the fun experience of playing someone's game recently, and noticing that it was just jittery and didn't look like it was doing better than 10FPS. I started making some theories on why that could be, asked for the source code, got it, and made a few changes. Sure enough, despite reducing the framerate, I got buttery smooth results with a few minor tweaks.
The guy who was running the game had gotten smoother framerate by changing client.FPS to 100fps, but it still wasn't very smooth. Unofortunately, this little tidbit of wisdom has been getting passed around as the only way to smooth out BYOND games, particularly pixel-movement games, and it's just... Not the magic bullet people think it is. In my case, I was working with a tiled movement system, so we'll start there. Some of this advice will apply to pixel movement games as well, but some of it won't.
Getting back to basics:
Let's start with some basics here. Let's start a test project with a few convenient defaults changed for us. We're gonna sit at 40 world/client FPS, and we're gonna run this whole thing in a 960x540 window with 16x16 tile icon size. We're also gonna include some temporary graphics just so we can see what's going on and determine how smooth our little project is.
You can download the example project here:
http://files.byondhome.com/Ter13/SmoothTest_src1.zip
Let's take a look at how she runs:
Please remember that all gifs will not be as smooth as actual gameplay. You can run the demos to test them in real time for yourself:
So yeah, it's a little too buttery. That's because the player is going to move every time the key-repeat on one of the arrow keys is triggered, which means for us, 40 times a second.
Less butter, more chunk:
Let's slow that down, so the player can move a reasonable speed of say, 4 tiles a second.
mob
var
step_delay = 2.5
tmp
last_step = -1#INF
next_step = -1#INF
proc
Step(dir,delay=step_delay)
if(next_step>world.time)
return 0
if(step(src,dir))
last_step = world.time
next_step = last_step + delay
return 1
else
return 0
This is going to be our nice little script to handle movement delays. Notice here that we're only handling movement delays in mob.Step(), that's because we don't want to completely immobilize movement always, just for player-directed stepping. This is a more flexible implementation than some of my previous explanations of this approach. I know the common approach is to embed it all in Move(), but honestly I don't like that anymore because it makes a mess later in development.
Since we're not embedding the delay in movement behavior by default though, this means we need to modify the built-in client-directed movement too:
client
Move(atom/loc,dir)
walk(usr,0)
return mob.Step(dir)
Now let's take a look at how she runs!
http://files.byondhome.com/Ter13/SmoothTest_src2.zip
Alright, it's so much worse. It just feels bad. I assure you, we're still running at 40fps. The issue is that BYOND's gliding behavior is gliding over 4 frames, and the movement is delayed across 10 frames. That creates a 6 frame jitter where the player has visually finished moving, but our code claims that it hasn't.
Back to butter:
This can be fixed with a simple algorithm to marry your glide size to your move delay.
First, let's modify our .dme file:
// DM Environment file for SmoothTest.dme.
// All manual changes should be made outside the BEGIN_ and END_ blocks.
// New source code should be placed in .dm files: choose File/New --> Code File.
// BEGIN_INTERNALS
// END_INTERNALS
// BEGIN_FILE_DIR
#define FILE_DIR .
// END_FILE_DIR
// BEGIN_PREFERENCES
#define DEBUG
// END_PREFERENCES
#define TILE_WIDTH 16 //our changes belong right here.
#define TILE_HEIGHT 16
// BEGIN_INCLUDE
#include "SmoothTest.dm"
#include "ui.dmf"
#include "test.dmm"
// END_INCLUDE
We want these defines to be global, so we're going to inject them in the DME file above the included files. These two defines should match your tile width and height for your project. In our case, that's 16.
Next, we need to add a single line of code to mob/Step()
mob
proc
Step(dir,delay=step_delay)
if(next_step>world.time)
return 0
glide_size = TILE_WIDTH / delay * world.tick_lag
if(step(src,dir))
last_step = world.time
next_step = last_step + delay
return 1
else
return 0
What we're doing here is always making sure that the mob's glide_size matches its delay according to the world framerate. If you use a different value for client.fps, don't worry about it, BYOND will adjust the final value of glide size on the client by the ratio of world and client fps. Just think about it in terms of server ticks and you'll be fine.
The result is that we're telling the glide to move at 1.6 pixels per frame in this case.
This means that movement won't be perfectly smooth, because of how the per-frame pixel offset will break out:
{1.6px,3.2px,4.8px,6.4px,8px,9.6px,11.2px,12.8px,14.4px,16px}
The decimal values will be truncated, resulting in glide positions of:
{1,3,4,6,8,9,11,12,14,16}
Or if we derive the change per-frame:
{1,2,1,2,2,1,2,1,2,2}
But really, there's no clean way to divide 10 by 16, so you won't get perfect smoothness out of any movement delays that aren't perfect factors of your tile size. That's fine. Nobody expects perfectly smooth motion with pixel graphics, and it will look fine enough even though it has a bit of jitter between steps when you focus on the background.
So now that we get this point, let's take a look:
http://files.byondhome.com/Ter13/SmoothTest_src3.zip
It's slow, sludgy, but very smooth movement. You can change mob.move_delay to get any speed you want out of the mob now though, so we're done. perfect smoothness. Pack it up, let's go home. This one is totally fixed, thanks Ter for telling us nothing new.
But I lied:
To see our new jank in action, we need to host the project on Dream Daemon, and then connect to it locally via Dream Seeker:
It's not fixed. We still have a bigly problem. The minute you host the game in a multiplayer environment, we're back in Jank Town, which as we all know is Flavor Town's sports rival. If you are making a single player game, you can ignore this next set of fixes, but if you are building your own NET dream, you need to pay attention to this bit, because everything you think about BYOND being shitty and laggy over a network is wrong.
Right, so how BYOND works at a fundamental level is the server works in a sort of sequence of events:
1) Execute the game's code on the server. 2) Gather up each client's viewport and send visual updates across the network. 3) Wait for any excess time. 4) Repeat.
If we're not exceeding the server's CPU budget to do all of this, this means that BYOND is sending updates to the viewports in roughly X intervals per second, X being your world's FPS.
The client doesn't really care about the server FPS too much except in calculating things like glides and sending repeating macro inputs, and building the blocking verb queue (verbs with set instant = 0 set can only be sent one per frame).
For the most part, the client tries to get inputs to the server as fast as humanly possible, so the minute that the client detects a windows message that a key has been pressed or released, or the mouse has been nudged, the server gets told it happened. This means that inputs can arrive and be processed at any point during a frame. This does not mean, however, that the client is waiting around on a response to that input to do anything. It just hangs out being told what to do by the server at a constant rate.
This means that even if your latency is Comcast bad, you are still receiving frame data at a regular pace. There's no reason that your movement should be janky at all and that latency should affect the smoothness of your game.
Even if the frames are arriving behind in time from the server (And they will, because that's how time works), they should still be arriving at a somewhat constant rate. Your inputs will be processed after X time, which is proportional to your latency, and you will receive visual indication of your input being processed after X time, which is again your latency.
The only time that you should see unsmooth results is when your internet connection is unstable and your ping to the server is varying wildly due to network congestion or just straight having a bad signal somewhere between you and the server --instances of packet loss will also emulate lag as the TCP layer requests the dropped packets and waits for them to arrive before continuing processing.
But Ter, we shouldn't have to worry about any of this, BYOND should take care of all of the networking for you. It already does, you don't have to worry about it, you just have to understand it, because you are making an online game. In fact, thanks to Lummox, we now have the tools to get around this problem, and have had for years. The trouble is, myself and some others have been passing on some outdated advice that is causing some confusion, and/or, most people simply don't read the newer advice we've been offering at all or just don't want to apply it. Instead, they assume that BYOND is trash and will never improve. Yes. The server-directed model was flawed for a number of years, but it's not as big of a weakness as it was even a few years ago as internet infrastructure improves and computing trends evolve.
Understanding the source of the jank:
Now that our project is running multiplayer, we can work out the cause of this random mess of jitters. I'll save you the magical mystery tour and just disclose the source of the problem right now:
client
Move(atom/loc,dir)
world.log << "[world.time]"
walk(usr,0)
return mob.Step(dir)
If you run the project in single player, you will see that your Move() proc is being called 40 times a second. You'll see time incrementing by 0.25 deciseconds every time that output is called while a key is being held down.
But if you run that project in multiplayer, you will see that your Move() proc is missing frames, even when connected from the localhost. Every now and again, it just isn't called on a frame that it ought to be.
This is an issue with repeating macros, and it is easily fixed. We just stop using repeating macros for anything that needs a frame-perfect granularity. Since the client doesn't really care if it's synced with the server or not, we don't rely on behavior that stems from the client being synced to the server. Instead of expecting smooth behavior from something that wasn't designed to offer smooth behavior, we only tell the server when a key has been pressed, and when a key has been released, and we do the work of repeating the movement ourselves.
As such, +REP macros should be considered deprecated and you should never use them unless you are making a single player game. You know that phantom problem that everyone has been mentioning for years about how BYOND can't do smooth multiplayer games? Everything works great in single player, but nothing works in multiplayer? This is the culprit. This little problem right here is the "lag" people reference when they talk about BYOND games.
Let's fix it.
First, we need to add some macros the dmf file:
If you find this tedious like I do, you can edit your dmf file in notepad manually:
macro "macro"
elem
name = "A"
command = "onMoveKey 8 1"
elem
name = "D"
command = "onMoveKey 4 1"
elem
name = "S"
command = "onMoveKey 2 1"
elem
name = "W"
command = "onMoveKey 1 1"
elem
name = "A+UP"
command = "onMoveKey 8 0"
elem
name = "D+UP"
command = "onMoveKey 4 0"
elem
name = "S+UP"
command = "onMoveKey 2 0"
elem
name = "W+UP"
command = "onMoveKey 1 0"
Next, we need to define the behavior of client.onMoveKey, which is going to be a new, hidden, instant verb.
client
var
move_dir = 0
verb
onMoveKey(dir as num,state as num)
set instant = 1, hidden = 1
if(state)
move_dir = dir
else if(move_dir==dir)
move_dir = 0
Next, we need to set up a loop that will process this input and call Move() every frame while a move key is being held down.
client
proc
MoveLoop()
set waitfor = 0
while(src)
if(move_dir)
Move(null,move_dir)
sleep(world.tick_lag)
Last, we need to actually start that loop:
client
New()
. = ..()
if(.)
MoveLoop()
http://files.byondhome.com/Ter13/SmoothTest_src4.zip
Using this setup, you can use WASD control for smooth movement, and the arrow keys or the numpad for janky movement. See the difference in multiplayer. It's night and day. (Special thanks to PeoplesRepublicofChina for giving me the idea of showing the difference simultaneously by leaving the +REP macros in place for one set of inputs, and having the move tracking loop working for the other set.)
Errors abound.
Let's up the framerate to 60 fps. One thing to note is that 60fps is not 60 frames per second in BYOND. It's actually a tick lag of 17ms, which is roughly 59fps... So... Yay? BYOND lacks a high precision timer that allows it to sleep for a fraction of a millisecond between frames. When we change our fps to 30fps or 60fps, it's still not smooth, despite all of our hard work:
http://files.byondhome.com/Ter13/SmoothTest_src5.zip
We're still not out of the woods yet. Before you go shouting about how BYOND is shit and you should just not bother because it shouldn't be this fucking hard to get something simple like smooth movement working, hang in there for a second. This one's only sort of BYOND's fault, because of a bad choice that was made with how world.time is calculated.
The problem stems from floating point error. BYOND tracks world.time as a floating point number. Floats can be really inaccurate. Y'all have seen me for years shouting about how 40fps is butter smooth and to not bother with 60fps... Well, floating point error is why. I've been leading you all wrong, and I'm sorry. I didn't think this through until I started fucking with another guy's game and I started tracking down missed frames. That's when I figured out the problem and started kicking myself for never giving enough of a shit to actually notice the issue.
mob
proc
Step(dir,delay=step_delay)
if(next_step>world.time) //the problem is right here.
return 0
glide_size = TILE_WIDTH / delay * world.tick_lag
if(step(src,dir))
last_step = world.time
next_step = last_step + delay
return 1
else
return 0
It's not an issue at lower frame rates. 10fps, 20fps, hell, even 25, 40, and 50 fps aren't really a big deal because they divide evenly into 1000 and you don't start seeing floating point errors start to stack up on you until your world has been running for a long time.
But if you try to run 30, 60, etc. FPS? Have fun, because those floating point errors come at you right out the gate and it makes your game look like it's lagging. You know how people say that BYOND can't handle 60fps for even simple games? Yeah, this is the source of that problem.
There's a trick to minimizing the impact of floating point errors, and it's just not relying on your math being accurate. You want your math to be a little fuzzy when you are working with floats, and give it just enough wiggle room that a slight inaccuracy in the value isn't going to completely wipe out your ability to move for a frame.
All we need to do to fix the problem, is change our comparison here a bit:
mob
proc
Step(dir,delay=step_delay)
if(next_step - world.time >= world.tick_lag / 10)
return 0
glide_size = TILE_WIDTH / delay * world.tick_lag
if(step(src,dir))
last_step = world.time
next_step = last_step + delay
return 1
else
return 0
And we're done. That's about as smooth as she gets. The new approach should now work at any FPS, and the worst jank you are going to see at 30/60 fps will be the very slight remainder of the rounding over 1000/FPS. I still recommend 40 FPS strongly, but there's just no convincing some people that it really is as smooth as pixel art needs to be.
http://files.byondhome.com/Ter13/SmoothTest_src6.zip
The code in this installment of the snippet series is mostly about demonstrating the problems that I believe that people are running into when they complain about BYOND not being able to handle even basic smooth transitions, and quick ways to implement fixes to them for the sake of demonstrating that it's not the client's fault that your framerate isn't smooth (unless you are talking about the remainder problem at 30/60fps, then yeah, that's the engine's fault that it's missing one frame every 334 and a third frames.)
I think there's a lot of misunderstanding about how input handling can be done, and Lummox has given all of us a lot of tools to better approach a reasonable workflow for developing more action-oriented games. Yeah, a lot of this snippet depends on tile glide smoothing to look smooth, but some of the people I have shared this information with were working on tile-based games and insisted the level of smoothing I managed to acheive in my demos wasn't possible at-scale in a BYOND game as large as theirs. --I've repeatedly proven that wrong. Many of these concepts will apply to pixel movement games as well, though.
Snippet Sunday #18 is going to talk more in depth about control scheme design for better key handling, basically chronicling all my experiments with the newest BYOND features related to macros, gamepads, and other such nonsense, and Snippet Sunday #19 is going to talk a bit about quality of life stuff for pixel movement games that will make your game feel more buttery at minimal cost to the server.
Last, I do not want to give the impression that you are ever going to achieve the level of responsiveness in a multiplayer BYOND game that you could in an engine like UE4, Unity, or Game Maker. You just aren't. But we can get close enough that most people won't care as long as your game is actually enjoyable to play. The jittering I see in nearly every tile-based BYOND game is easily fixable right now. It has been easily fixable for several years now. While what we have is not perfect, it's light years beyond where we were pre-Lummox, and there's no reason that the engine has been tainted so badly by preventable jitter to the point where well-informed people still seem to think that the cause of the current level of jitter in your average BYOND game is the fault of the engine and is completely unfixable without a topdown rewrite of the engine.