Gah! I should have realized this sooner.
The internal representation for PyGame’s rects are integers. This is why characters in TinyTyranny either moved strongly in one direction or not at all! I think this is also why Run! Jump! Forever! (windows version) had some time dependencies on jump height. Python was casting my floating point positions to ints whenever I put them into a Rect. So frustrating!

After the jump, the alternative FloatRect I wrote. I didn’t bother to include all the functionality of PyGame rects, but just picked out the functions I actually use.
class FloatRect(): def __init__(self, left, top, width, height): self.left = float(left) self.top = float(top) self.width = float(width) self.height = float(height) def centerX(self): return self.left + ( self.width / 2.0 ) def centerY(self): return self.top + ( self.height / 2.0 ) def y(self): return self.top def x(self): return self.left def __getitem__(self, key): if key == 0: return self.left elif key == 1: return self.top elif key == 2: return self.width elif key == 3: return self.height else: return None def setLeft(self, left): self.left = left def setRight(self, right): self.left = right - self.width def setTop(self, top): self.top = top def setBottom(self, bottom): self.top = bottom - self.height def getLeft(self): return self.left def getRight(self): return self.left + self.width def getTop(self): return self.top def getBottom(self): return self.top + self.height def collideRect(self, rect2): myLeft = self[0] myRight = self[0] + self[2] myTop = self[1] myBottom = self[1] + self[3] itsLeft = rect2[0] itsRight = rect2[0] + rect2[2] itsTop = rect2[1] itsBottom = rect2[1] + rect2[3] inX = False inY = False inX = (( myLeft >= itsLeft ) and ( myLeft <= itsRight )) or (( myRight >= itsLeft ) and ( myRight <= itsRight )) inY = (( myTop >= itsTop ) and ( myTop <= itsBottom )) or (( myBottom >= itsTop ) and ( myBottom <= itsBottom )) return inX and inY def collidePoint(self, point): return ( point[0] >= self.left() and point[0] <= self.right() ) and ( point[1] >= self.top() and point[1] <= self.bottom() )