In XeGrader plus, all movements are corrected to precise pixel units to create an authentic retro atmosphere. Normally, Godot uses
Vector2, which is a floating-point vector, for coordinates. Even if you use a 320x200 pixel background, if the client area is 1440x900 pixels, the drawing position is processed in those actual coordinate units, meaning movement won't be locked to precise "game pixels." In this article, I will explain how to implement accurate 1-pixel movement in Godot.Pixel Snap:
Godot has a "Pixel Snap" feature to support pixel-perfect displays. However, this snaps to the monitor resolution of the execution environment, so it cannot maintain a precise 1-pixel movement relative to the game's internal resolution.
Godot has a "Pixel Snap" feature to support pixel-perfect displays. However, this snaps to the monitor resolution of the execution environment, so it cannot maintain a precise 1-pixel movement relative to the game's internal resolution.
- Node Structure
Generally, a game character's node structure likely looks like this:
+ Node2D (Character Root, Script location) ├ CharacterBody2D (For physics) ├ CollisionShape2D (Collision detection) └ Sprite2D (For display)
You would typically move the character by changing the
position or global_position of the CharacterBody2D, setting direction and speed in velocity, and calling move_and_slide(). This is the standard way. However, with this setup, precise pixel-unit implementation is impossible because the display is "carried" directly on top of the coordinates calculated by the system. Therefore, we change the node structure as follows:
+ Node2D (Character Root, Script location) ├ CharacterBody2D (For physics) │ └ CollisionShape2D (Collision detection) └ Sprite2D (For display)
By separating the movement logic from the display, the visual representation is no longer directly influenced by the sub-pixel physics results.
- Correcting Display to Pixel Units
Since we have decoupled the actual position from the display, the sprite will no longer follow the body automatically. You must manually assign coordinates to the
Sprite2D. By rounding the coordinates to the nearest integer before passing them to the Sprite2D, we guarantee a correct pixel-unit display. Ideally, this coordinate conversion and assignment should happen immediately after the movement is processed.
@onready var chara = $Sprite2D @onready var body = $CharacterBody2D func _physics_process(_delta) -> void: body.velocity = ... # Movement input processing, etc. body.move_and_slide() chara.global_position = body.global_position.round()
In XeGrader plus, I offer MSX2 and ORIGIN (PC-8001mkII) modes via DLC. Since these are retro hardware, movement is handled in byte units. On the MSX2's SCREEN 5, a byte unit results in 2-pixel increments. On the PC-8001mkII, it's 4-pixel increments. To replicate this, I use the following coordinate conversions:
# MSX2 chara.global_position = (body.global_position / 2).round() * 2 # PC-8001mkII chara.global_position = (body.global_position / 4).round() * 4
This achieves accurate pixel-unit movement for specific hardware emulation.
- Correcting Judder in Diagonal Movement
While horizontal and vertical movement look fine, diagonal movement can result in a "stuttering" or jittery appearance. This is best understood by looking at this diagram.

This happens because the internal coordinates are floating-point numbers. This jagged movement state is called "judder," and it has been loathed since ancient times (slight exaggeration). How do we fix this? The answer is simple: ensure the source coordinates are integers. Judder occurs because of the fractional parts of the coordinates. To eliminate the stutter, you must eliminate the decimals.
However, there is a catch: if you can't use floating-point numbers for position, you can no longer rely on Godot's built-in physics movement. In XeGrader plus, I use
RayCast2D for collision checks, but the actual movement logic is entirely custom. To handle this, I use the following common properties for all characters:
var vpos: Vector2i # Current position var dir: Vector2i # Movement direction var speed: float # Movement speed (seconds) var decimal: float # Accumulation timer
The concept is similar to fixed-point math. When the
decimal timer underflows, we determine that the "digit" has changed and update vpos. This logic specifically targets 8-way movement. To prevent diagonal judder, the X and Y coordinates must change at exactly the same time. The implementation flow is as follows:- Assign the movement direction to dir.
- Assign speed to decimal.
- Subtract delta from decimal.
- When the result is 0 or less, add dir to vpos and add speed back to decimal.
A few notes: individual elements in the
dir (Vector2i) should only be -1, 0, or +1. A smaller speed value results in faster movement. To ensure a circular movement range, multiply the speed by $\sqrt{2}$ for diagonal directions. For example, if the base speed is 0.1, the diagonal speed becomes 0.141421.Steps 1 and 2 are only necessary at the start of movement or when the direction changes. For continuous movement, simply repeat steps 3 and 4. Here is a sample implementation:
const SQRT2 = 1.41421356 var vpos: Vector2i # Current position var dir: Vector2i # Movement direction var speed: float # Movement speed (seconds) var decimal: float # Accumulation timer func _physics_process(delta: float) -> void: # 3. Update timer decimal -= delta # 4. Move by 1 dot when time elapses (0 or less) if decimal <= 0: vpos += dir var weight = SQRT2 if dir.x * dir.y != 0 else 1.0 decimal += speed * weight # Pass integer-converted coordinates to Sprite $Sprite2D.global_position = Vector2(vpos)
This implementation achieves perfectly synchronized diagonal movement. Note that with this method,
If you enjoyed this article, please add the game to your Wishlist!
CharacterBody2D becomes merely a container for collision detection; you can't use its convenient movement functions. This is the tradeoff for precise pixel control. Which method you choose is up to you, but this is how we do it in XeGrader plus.If you enjoyed this article, please add the game to your Wishlist!

コメント