#3706·ebiten

internal/ui: consecutive touches can share a TouchID and collapse into one touch

Author: hajimehoshiCreated Sep 15, 2026Updated Sep 15, 2026
Labelsbugos:androidos:iosos:js

Ebitengine Version

main (9202a8f61, v2.11.0-alpha). The behavior is present in every released version.

Operating System

  • Windows
  • macOS
  • Linux
  • FreeBSD
  • OpenBSD
  • Android
  • iOS
  • Nintendo Switch
  • PlayStation 5
  • Xbox
  • Web Browsers

Go Version (paste your go version output)

go version go1.27.0 darwin/arm64

What steps will reproduce the problem?

  1. Run examples/drag on a touch device (Android, iOS, or Chrome on Android).
  2. Drag a sprite with one finger, lift it, and land a second finger somewhere else so that the lift and the landing fall between two consecutive ticks. Lowering the TPS (ebiten.SetTPS(10)) or a slow Update makes this easy to hit.

A multi-touch variant on Android needs no timing luck on the tick rate: hold finger A, add finger B, lift A, then land a new finger C. Android hands C the lowest free pointer ID, which is the ID A had.

What is the expected result?

The first touch is reported as released and the second as a new touch: inpututil.AppendJustReleasedTouchIDs reports the first one, inpututil.AppendJustPressedTouchIDs reports the second one, and the two touches have distinct TouchIDs.

What happens instead?

Both touches carry the same TouchID, so the two per-tick snapshots are indistinguishable from one continuous touch:

  • Neither edge is reported: no just-released for the first touch and no just-pressed for the second.
  • inpututil.TouchPressDuration keeps counting across both touches.
  • TouchPosition jumps from the first finger's last position to the second finger's position, and TouchPositionInPreviousTick refers to the other finger.

In examples/drag, the sprite grabbed by the first finger teleports to the second finger and stays attached to it, and no stroke starts for the second finger even when it landed on another sprite.

Anything else you feel useful to add?

Root cause: the touch state is a per-tick snapshot of the touches that are currently down, keyed on the ID the platform assigns, and every platform reuses IDs as soon as they are free:

  • Android: MotionEvent pointer IDs are passed through (mobile/ebitenmobileview/input_android.go). The first finger is always 0 and a new finger takes the lowest free ID.
  • iOS: getIDFromPtr allocates max(live IDs) + 1 (mobile/ebitenmobileview/input_ios.go), so once every finger lifts the next touch is ID 1 again.
  • Browsers: Touch.identifier is passed through (internal/ui/input_js.go). Chrome reuses 0, 1, ...

Keys and mouse buttons record press and release InputTime stamps, so edges that fall inside one tick survive. Touches have no equivalent, so a release followed by a press of the same ID between two samplings collapses. (The same model also drops a tap that begins and ends inside one tick entirely; that is the touch counterpart of the key/mouse case and a separate symptom.)

A possible fix: allocate Ebitengine's own monotonically increasing TouchID where platform events enter internal/ui, per event rather than per tick, mapping a platform ID to a fresh ID whenever it reappears after being absent. That keeps consecutive touches distinct regardless of the tick rate. Recording press and release InputTime for touches, as for keys, would additionally preserve same-tick taps.

This report comes from reading the current code; it has not been reproduced on a device.

Filed by Claude (Claude Code), on behalf of @hajimehoshi.