Skip to main content

Getting started with SG Physics 2D and deterministic physics in Godot

Submitted by David Snopek on Monday, 2021-12-13 @ 9:18am
Shapes and mathy looking diagrams

For the last few months, I've been working on converting Retro Tank Party to use rollback netcode. One of the requirements for rollback netcode, is that any physics or collision detection in your game must be deterministic.

A physics engine is "deterministic" if it will play out exactly the same on two different computers, if both have the exact same starting state.

Unfortunately, the built-in physics engine in Godot is NOT deterministic!

Usually this is fine - most games don't need deterministic physics or collision detection. But some do, and my game is one of them. :-)

So, I implemented my own little Open Source (MIT licensed) 2D physics engine for Godot called... SG Physics 2D.

In this tutorial, I'll walk you through how you can start using SG Physics 2D to implement deterministic collision detection in your game!

Downloading and installing

SG Physics 2D is implemented as a Godot module, which means it needs to be compiled into Godot. This is unlike a GDNative plugin, which you can just drop into your project, and use with the normal version of Godot downloaded from godotengine.org.

However, I've made pre-compiled binaries for Windows, Linux, MacOS and HTML5 that you can download!

Go to the releases page on GitLab.com and download the latest version for your operating system.

After extracting the ZIP file, you'll see that it contains 3 things: the Godot editor executable, and the debug and release export templates for that platform.

Just run the Godot editor executable from this ZIP file and you're ready to get started!

If you want to compile it yourself, or for a platform that doesn't have pre-compiled binaries yet (ex. Android, iOS), see the instructions in the README file.

A quick introduction to fixed-point math

Normally, decimal numbers with a fractional part are represented as hardware-based floating-point numbers.

However, the result of floating-point math can be slightly different on different CPUs, operating systems or versions - which can break determinism!

There's a couple solutions to this problem:

  • Soft floats: implementing floating-point math in software, rather than using the hardware floating-point features of your CPU.
  • Fixed-point math: using integers to represent decimals with a fixed number of bits reserved for the fractional part.

SG Physics 2D went with fixed-point math.

Specifically, we use 64-bit signed integers, with the first 16 bits reserved for the fractional part.

This means the smallest fractional part that can be represented is one 65,536th (ie. 1/65536) and we can theoretically represent "real world" numbers in the range from -140737488355327 to 140737488355328.

To convert from a "real world" number to our fixed-point format, you'd multiply it by 65536 and discard any remaining fractional part. So, for example, 4.25 is 278528 in our fixed-point representation.

In GDScript, we use normal integers for fixed-point values, and provide some utilities to work with them, for example:

  • SGFixed.mul(a, b): multiplies two fixed-point numbers
  • SGFixed.div(a, b): divides two fixed-point numbers
  • SGFixed.to_float(a): converts a fixed-point number to a float

BTW, for addition and subtraction, you can just add or subtract the integers the normal way! However, there is an SGFixed.add(a, b) and SGFixed.sub(a, b) for completeness.

And, we provide some new fixed-point types that mirror the built-in Godot types, for example:

  • SGFixedVector2 replaces Vector2
  • SGFixedTransform2D replaces Transform2D
  • SGFixedRect2 replaces Rect2

Limitations and avoiding overflow

If the result of a math operation will produce a number outside the range of values that we can represent, that's called an "overflow".

If an overflow happens, you're not going to the result you're looking for, or your game may even crash.

To prevent overflow, any SGFixedVector2 shouldn't have an X or Y component outside the range -1966080000 to 1966080000 (which is -30000.0 to 30000.0 in "real world" numbers)!

Even though we can represent much bigger numbers, once you start performing any math on that vector, the physics engine will need to use intermediate numbers that are several times larger than the input you gave it, which can lead to overflow - and things just not working.

So, this means any 2D scene you create can only be up to 60,000 pixels by 60,000 pixels. This is fine for many games, but if you need to have bigger scenes, then, unfortunately, SG Physics 2D won't work for you.

Let's get started!

Ok, that's enough theory! Let's put this into practice. :-)

We're going to make a very simple demo, with a player-controlled kinematic body that can crash into some static bodies.

Create an "Obstacle" scene

After you start up the Godot editor (using a build with SG Physics 2D compiled in, of course), create a new project.

The first scene we're going to make will be an obstacle that we'll instance into our main scene, so that our character has something to crash into:

  1. Make a new scene with an SGStaticBody2D as the root.
  2. Add a ColorRect node and make it a little bigger (say, 100x100 pixels).
  3. Add an SGCollisionShape2D node (also as a child of the root SGStaticBody2D node).
  4. With the SGCollisionShape2D node selected, click the dropdown next to the "Shape" property in the inspector, and select "New SGRectangleShape2D".
  5. Position and resize the collision shape to match the position and dimensions of the ColorRect. If you made the ColorRect 100x100, then you can set the shape's "Extents" to 3276800x3276800 - which is 50x50 in our fixed-point format - and the "Fixed Position" to (3276800, 3276800).
  6. Save the scene as "Obstacle.tscn".

In the end, the scene should look something like this:

Obstacle scene in the Godot editor

There's a couple things you've probably noticed during this process:

  • We're using an SGStaticBody2D and SGCollisionShape2D, which are roughly equivalent to Godot's normal StaticBody2D and CollisionShape2D, respectively.
  • Currently, SGCollisionShape2D supports only two shapes: SGRectangleShape2D and SGCircleShape2D, which is much fewer than Godot's builtin physics engine. However, we do support ray casts and arbitrary convex polygons via the SGRayCast2D and SGCollisionPolygon2D nodes - but we're not going to be using them in this starter tutorial.
  • All of our custom nodes have two sets of transform properties: the normal "Position", "Rotation" and "Scale" from Godot (which use normal floating-point numbers) and the "Fixed Position", "Fixed Rotation" and "Fixed Scale" properties (which use fixed-point numbers).
    Godot inspector showing both float-point and fixed-point transform properties
    • In the editor, if you change either the "Position" or "Fixed Position", it'll update the other property - and the same goes for the rotation and scale properties too. However, in game, it only goes in one direction: updating the fixed-point property will update the floating-point one.
    • The fixed-point values will be used by the physics engine, the floating-point values are only used for visuals. So, in your game you'll likely only update the fixed-point values, although, you can do some interesting visual tricks by changing the floating-point values to move only the visuals.

In general, this process should feel pretty similar to working with Godot's builtin physics engine!

Create the "Character" scene

Next, we're going to make a player-controller character scene that can move around:

  1. Make a new scene with an SGKinematicBody2D as the root.
  2. Drag the logo.png into the scene, which will create a Sprite node. Then reset its "Position" to (0, 0). Yes, like most Godot tutorials, we're going to use the Godot logo. :-)
  3. Add an SGCollisionShape2D node (also as a child of the root SGKinematicBody2D node).
  4. Just like with the "Obstacle" scene, set the "Shape" on the SGCollisionShape2D to a new SGRectangleShape2D which covers the Sprite node.
  5. Save the scene as "Character.tscn".

So far, the scene should look like this:

Character scene in the Godot editor

Next, attach a new script to the top-level node named "Character.gd" with the following contents:

extends SGKinematicBody2D
 
const MOVEMENT_SPEED := 65536*4
 
# We're still using the _physics_process() method even though we aren't using
# Godot's builtin physics engine because it gives us a fixed tick. However,
# you could certainly implement your own fixed tick, you don't have to use
# _physics_process() with SG Physics 2D.
func _physics_process(delta: float) -> void:
	var vector := SGFixedVector2.new()
	# We're converting from a floating-point vector to fixed-point. Normally,
	# you'd NEVER do this if you wanted to have deterministic calculations,
	# however, with user input it's generally OK. In a multiplayer game, a
	# player's input originates in one of the game clients, and then is shared
	# with all the rest, so it'll always be exactly the same.
	vector.from_float(Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down").normalized())
 
	# Multiply the vector by MOVEMENT_SPEED.
	#
	# We DO NOT take the delta into account. If you introduce a floating-point
	# value into any math (even after converting it), the result could potentially
	# be different on other computers or platforms. In this example, that doesn't
	# matter at all, but imagine this same code is running on multiple computers
	# in a multiplayer game.
	vector.imul(MOVEMENT_SPEED)
 
	# Hey, we've got a move_and_slide() just like KinematicBody2D!
	move_and_slide(vector)

This should look very simple and pretty familiar. Most of my notes are in the comments above, but I'll add:

  • Notice how vector math has to be done with methods like vector.imul(MOVEMENT_SPEED) rather than operators like vector * MOVEMENT_SPEED. This is because we can't override operators in GDScript. Instead, we have methods like mul() for multiply and imul() for in-place multiply (ie. the *= operator).
  • Also, an SGFixedVector2 is a reference-counted object, rather than a primitive like Vector2. That means it will always pass by reference, rather than value.  So, if you need to make a copy, you'll need to actually call vector.copy().

In Godot 4, we'll be able to fix both things by using Vector2i objects for fixed-point vectors, which are primitives just like Vector2's. I'm really looking forward to porting SG Physics 2D to Godot 4, which will also be able to be a GDExtension plugin (Godot 4's replacement for GDNative) rather than a module that needs to be compiled into Godot.

Putting it together in the "Main" scene

Finally, let's put it all together:

  1. Make a new scene with an Node2D as the root.
  2. Instance the "Obstacle.tscn" scene a bunch of times and scatter them around the scene. Rotate them into various orientations so our character has some interesting things to collide with.
  3. Instance the "Character.tscn" scene once and place it in the center of the scene.
  4. Save the scene as "Main.tscn".
  5. Press F5 or click the play button in the upper-right corner to start the game. It'll ask you to select a main scene - either select Main.tscn or click the "Select current" button.
  6. Use the arrow keys to move the Godot icon around and crash into some of the rotated squares!

The scene should look something like:

The Main scene in the Godot editor

This is pretty simple and works exactly the way you'd expect.

I have only one note: we're adding the SGStaticBody2D's and SGKinematicBody2D (which both descend from SGFixedNode2D) as children of a plain ol' Node2D.

SGFixedNode2D (and any of the node's that descend from it) will have their "Fixed Position", "Fixed Rotation" and "Fixed Scale" offset by their parent's fixed-point transform, similar to how Node2D's will be offset by their parent's "Position", etc.

But if an SGFixedNode2D is a child of a Node2D, things can get weird. :-) The floating-point transform of the SGFixedNode2D (which affects the visuals) will be offset by the Node2D's floating-point transform, but it's fixed-point transform won't be affected at all. This can cause confusing behavior where a node appears to be in one place visually, but is at a different location according to the physics engine.

So, it's OK to mix trees of Node2D and SGFixedNode2D's (and there are occasionally compelling reasons to do so) but be careful!

Let's use an SGArea2D too

Physics bodies are great, but most Godot games also make extensive use of areas. Let's add an SGArea2D to the mix so we can see how it's different from a vanilla Area2D.

  1. Make a new scene with an SGArea2D as the root.
  2. Add an SGCollisionShape2D node.
  3. With the SGCollisionShape2D node selected, click the dropdown next to the "Shape" property in the inspector, and this time select "New SGCircleShape2D" so we can try a different shape.
  4. Click on the SGCircleShape2D in the inspector (so it expands its properties) and set the "Radius" to 1638400 (this is 25 in our fixed-point format)
  5. Save the scene as "Spotlight.tscn".

It should look something like this:

The Spotlight scene in the Godot editor

Next, attach a new script to the top-level node named "Spotlight.gd" with the following contents:

extends SGArea2D
 
onready var collision_shape = $SGCollisionShape2D
 
const MOVEMENT_SPEED := 65536*2
const MOVEMENT_FRAMES := 60
 
var vector: SGFixedVector2
var frame_count := MOVEMENT_FRAMES
var last_overlapping_bodies: Array
 
func _ready() -> void:
	vector = SGFixed.vector2(MOVEMENT_SPEED, 0)
 
func _physics_process(delta: float) -> void:
	# Update our 'fixed_position', which will update our 'position' and move
	# the circle visually (but not right away, it'll do it just before this node
	# is actually rendered).
	fixed_position.iadd(vector)
 
	# The change to 'fixed_position' won't immediately update the area's position
	# in the physics engine - we have to manually call sync_to_physics_engine().
	# We didn't need to do this in Character.gd because move_and_slide() syncs to
	# the physics engine in the process of moving and sliding.
	sync_to_physics_engine()
 
	# Unlike Area2D, there are no signals that will be called when a body enters
	# or exits an SGArea2D - we have to manually check for any overlapping bodies.
	var overlapping_bodies = get_overlapping_bodies()
 
	# First, we'll unmodulate any bodies that we modulated last frame.
	for body in last_overlapping_bodies:
		body.modulate = Color(1.0, 1.0, 1.0)
 
	# Then, let's modulate any bodies that we are currently overlapping.
	for body in overlapping_bodies:
		body.modulate = Color(1.0, 0.0, 0.0)
 
	# Store these for the next frame.
	last_overlapping_bodies = overlapping_bodies
 
	# Decrement the frame count. Once it equals 0, switch directions and start
	# the count over.
	frame_count -= 1
	if frame_count <= 0:
		vector.x = -vector.x
		frame_count = MOVEMENT_FRAMES
 
# Draw the circle so we can see it in game.
func _draw() -> void:
	draw_circle(Vector2.ZERO, SGFixed.to_float(collision_shape.shape.radius), Color(0.0, 0.0, 0.0, 0.35))

Again, I put a lot of my notes into comments in the source code, but there's a couple things I want to call out:

  • SG Physics 2D has a split between the SGArea2D node and an "internal area" inside the physics engine. This split is super common for physics engines in games, and, in fact, the built-in Godot physics engine works this way too! The difference is, the built-in Godot physics engine will automatically sync from the node to the physics engine at various points, but SG Physics 2D requires you to explicitly call sync_to_physics_engine().
  • When using Area2D, you most commonly connect to signals (like 'body_entered' or 'body_exited') to detect if anything enters or exits the area. However, SGArea2D has no signals - you need to explicitly call get_overlapping_bodies() or get_overlapping_areas().

The reason for both of these things is an important principle in SG Physics 2D:

SG Physics 2D won't do anything until you ask it to!

There is a lot more to making your game deterministic than just deterministic math. You also need to make sure that everything in your game logic happens in a predictable order. To do that, you need to be in control of exactly when data is traveling into and out of the physics engine.

Adding in the last piece!

Let's add the last piece of the puzzle:

  1. Open "Main.tscn".
  2. Instance "Spotlight.tscn" and put it somewhere to the left of the character.
  3. Press F5 or click the play button in the upper-right corner to start the game.

When the "spotlight" passes over the character (or any of the obstacles) it'll turn them red:

Animated GIF showing character moving and turning red

And that's it!

There's a some more I could show (like polygons and ray casts) and A LOT more that we could cover about ensuring that your game logic is deterministic, but I feel like this covers everything that you need to get started.

Wait, no, rigid bodies?! Where's the actual physics?

Heh, ok, you're right! :-)

At the moment, it'd be more accurate to call SG Physics 2D a "collision engine" rather than a "physics engine" because it doesn't support any rigid body physics simulation.

That's mainly because I don't need rigid body physics for my game, Retro Tank Party. However, it would certainly be possible to add an "SGRigidBody2D", taking advantage of all the of math and collision logic that exists already.

If I ever need that for one of my games, I'll add it. Or, if anyone out there wants to dive into the code and make a merge request, I'd be happy to review and merge it.

How to learn more?

This tutorial was meant to be a quick introduction. I'm definitely planning to create some more tutorials (text and video) covering more advanced topics.

But if you want to dig deeper, for the time-being, these are the best places to look for more help:

  • The API documentation in the engine. Just press F1 or go to "Help" -> "Search Help", and search for any of the classes from SG Physics 2D and read the API documentation. It's still a work-in-progress, but it's a decent place to start (contributions welcome!).
  • The project page on GitLab. The README has some more information, and if you find any bugs you can open an issue. Or, if you'd like to contribute to the project, you could make a merge request!
  • The demos in the source code for SG Physics 2D. Particularly the "move_and_collide" demo, which demonstrates move_and_collide() as well as SGRayCast2D and SGCollisionPolygon2D.
  • The Snopek Game's Discord. Feel free to come by and chat with me, and other members of the Snopek Games community.

I can't wait to see what folks use SG Physics 2D to make! Please leave a comment below and let me know what you're creating. :-)

Level:
Intermediate
Game / Project:

Subscribe!

Subscribe to get an email when new articles or videos are posted on SnopekGames.com!

* indicates required

Comments

Submitted by Vikfro on Monday, 2021-12-13 @ 11:32am Permalink

I like the article, it's well-written, going straight to the point and presenting enough but not too much context.
Why do you need deterministic physics (or just deterministic collision) for your game?

Submitted by David Snopek on Monday, 2021-12-13 @ 12:05pm Permalink

Thanks!

Why do you need deterministic physics (or just deterministic collision) for your game?

In my game, I'm implementing rollback & prediction netcode, where only the inputs are synchronized between the clients. In order for this to work, the game must play out exactly the same on all clients, given the same starting state and input. This form of network synchronization is most commonly used in competitive fighting games, and (like all things) has a specific set of pro's and con's.

I'm also planning to release the core of my rollback & prediction netcode to the Godot asset library, and at that point, I'll make some articles and videos digging more into the details of how that works, and what the tradeoffs are.

Add new comment
The content of this field is kept private and will not be shown publicly.

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
CAPTCHA
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.