Guide

Guide Input Mouse and pointer

Mouse and pointer

Work with pointer events across mouse and touch-compatible devices.

Intro ~3 min read

What you'll learn

  • read unified pointer events across mouse and touch
  • translate pointer position into world space

Before you start

Mouse and pointer

ExoJS unifies mouse, touch, and pen input under a single pointer system built on the browser’s Pointer Events API. Every pointer is represented by a Pointer instance with a canvas-pixel position, button state, pressure, tilt, and a slot index. The InputManager tracks up to 16 simultaneous pointers — enough for ten-finger multitouch, plus a mouse, plus a pen — with no additional configuration.

Pointer signals

The application’s input manager emits these pointer signals:

SignalFires when
onPointerDownA pointer presses (mouse button down, finger touches, pen contacts)
onPointerMoveA pointer moves
onPointerUpA pointer releases
onPointerTapA pointer releases without significant movement
onPointerSwipeA pointer releases after moving beyond the distance threshold
onPointerEnterA pointer enters the canvas boundary
onPointerLeaveA pointer leaves the canvas boundary
onPointerCancelThe browser cancels the pointer (interruption, gesture takeover)

Each signal’s callback receives the Pointer instance:

app.input.onPointerMove.add(pointer => {
    this.crosshair.position.set(pointer.x, pointer.y);
});

app.input.onPointerTap.add(pointer => {
    this.placeBuilding(pointer.x, pointer.y);
});

The pointer’s position is in canvas-local pixel space — (0, 0) is the top-left of the canvas, regardless of CSS size or page scroll.

Primary pointer convenience

Pointer.X, Pointer.Y, and Pointer.Active are channel constants that always reference the primary pointer (normally the mouse, or the first finger to touch). These can be used with the binding API just like keyboard channels:

this.inputs.onActive(Pointer.Active, () => {
    this.isPointing = true;
});
this.inputs.onStop(Pointer.Active, () => {
    this.isPointing = false;
});

The signal-style API (app.input.onPointerMove) is typically more ergonomic for pointer input than the binding API, since pointer data includes position, pressure, and tilt — not just an active/inactive boolean.

Multi-touch

Each pointer gets a slot index (0–15). Per-slot channel constants let you track individual fingers for multi-touch:

import { Pointer } from '@codexo/exojs';

// Slot 1 (second finger)
this.inputs.onActive(Pointer.Slot1X, value => {
    this.touchX = value * this.app.canvas.width;
});

For most multi-touch use cases, tracking pointer instances by ID via the signal API is simpler:

init(loader) {
    this.pointers = new Map();

    this.app.input.onPointerDown.add(p => {
        this.pointers.set(p.id, { x: p.x, y: p.y });
    });

    this.app.input.onPointerMove.add(p => {
        if (this.pointers.has(p.id)) {
            this.pointers.set(p.id, { x: p.x, y: p.y });
        }
    });

    this.app.input.onPointerUp.add(p => {
        this.pointers.delete(p.id);
    });

    this.app.input.onPointerCancel.add(p => {
        this.pointers.delete(p.id);
    });
}

Gesture recognition

The input manager includes built-in gesture recognizers for common multi-touch patterns:

app.input.onPinch.add((scale, center) => {
    // scale > 1 = spreading fingers, scale < 1 = pinching
    this.camera.zoom *= scale;
});

app.input.onRotate.add((angleDelta, center) => {
    // angleDelta in radians — positive = clockwise rotation
    this.map.rotate(angleDelta);
});

app.input.onLongPress.add(pointer => {
    // pointer held without significant movement for >= 500 ms
    this.showContextMenu(pointer.x, pointer.y);
});

These are high-level events built on top of the raw pointer signals. They track two pointers for pinch/rotate and a single pointer with a 500 ms timer for long-press. No additional setup is required beyond subscribing.

Pointer-to-world coordinates

Pointer positions are in canvas pixel space. To map them into scene world coordinates — for placing objects, selecting units, or aiming — invert the active view’s transform:

import { Vector } from '@codexo/exojs';

_toWorld(screenX, screenY) {
    const { width, height } = this.app.canvas;
    const clipX = (screenX / width) * 2 - 1;
    const clipY = 1 - (screenY / height) * 2;

    const matrix = this._view.getInverseTransform();
    return new Vector(
        matrix.a * clipX + matrix.b * clipY + matrix.x,
        matrix.c * clipX + matrix.d * clipY + matrix.y,
    );
}

getInverseTransform() returns an ExoJS Matrix, and that matrix exposes the fields used above: .a, .b, .c, .d, .x, .y.

If the default view is active (no camera transform), the canvas-pixel position is already the world position and no mapping is needed.

Interactive sprites

A Sprite (or any RenderNode) can be made interactive and draggable directly:

init(loader) {
    this.sprite = new Sprite(loader.get(Texture, 'hero'));
    this.sprite.setAnchor(0.5);
    this.sprite.setPosition(400, 300);

    this.sprite.interactive = true;  // respond to pointer events
    this.sprite.draggable = true;    // enable drag behavior
}

Setting interactive = true makes the sprite participate in hit testing. Setting draggable = true lets the user drag it with the pointer — the engine handles pointer tracking and position updates.

Pointer properties

The Pointer instance passed to every signal callback carries the properties you typically need:

pointer.x, pointer.y   // canvas-local pixel position
pointer.position       // Vector with current x and y
pointer.buttons        // bitmask: 1=left, 2=right, 4=middle
pointer.isPrimary      // true for mouse or first finger to touch
pointer.type           // 'mouse', 'touch', or 'pen'

For hover-vs-drag discrimination, check pointer.buttons in onPointerMove — it fires for every movement, including when no button is pressed. The API reference documents the full property set including pressure, tiltX/tiltY (pen tilt), startPos (press-down position for tap/swipe logic), and currentState.

When to use which

  • Signal API (app.input.onPointerMove etc.) for raw pointer data — position tracking, custom gesture detection, drawing apps.
  • Binding API (this.inputs.onActive(Pointer.Left) etc.) for simple button/binary state — holding mouse button to fire, right-click context menus.
  • Interactive sprites (interactive/draggable) for drag-and-drop and click-on-object behavior without manual hit testing.
  • Gesture signals (onPinch/onRotate/onLongPress) for map zoom/rotate and touch-hold menus.

Examples

Mouse and Pointer Pointer Open in Playground View source
import { Application, Color, Graphics, Scene, Sprite, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 12, 20),
    loader: {
        basePath: 'assets/',
    },
});

// Everything the pointer pipeline reports, surfaced at once:
//   - live position (onPointerMove)
//   - pressed-button bitmask (Pointer.buttons: 1=left, 2=right, 4=middle)
//   - frame-to-frame movement delta
//   - a click counter (onPointerTap fires on a press+release without a drag)
//   - a draggable sprite (the engine's built-in drag on an interactive node)
class MouseAndPointerScene extends Scene {
    private ship!: Sprite;
    private crosshair!: Graphics;
    private pointer = { x: 400, y: 300 };
    private previous = { x: 400, y: 300 };
    private deltaX = 0;
    private deltaY = 0;
    private buttons = 0;
    private clicks = 0;
    private hud!: ReturnType<typeof mountControls>;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { ship: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.pointer = { x: width / 2, y: height / 2 };
        this.previous = { x: width / 2, y: height / 2 };

        this.ship = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setPosition(width / 2, height / 2);
        this.ship.interactive = true;
        this.ship.draggable = true;
        this.crosshair = new Graphics();

        this.app.input.onPointerMove.add(pointer => {
            this.pointer.x = pointer.x;
            this.pointer.y = pointer.y;
            this.buttons = pointer.buttons;
        });
        this.app.input.onPointerDown.add(pointer => {
            this.buttons = pointer.buttons;
        });
        this.app.input.onPointerUp.add(pointer => {
            this.buttons = pointer.buttons;
        });
        this.app.input.onPointerTap.add(() => {
            this.clicks++;
        });

        this.hud = mountControls({
            title: 'Mouse and Pointer',
            controls: [
                { keys: 'Move', action: 'track position + delta' },
                { keys: 'Click', action: 'count taps' },
                { keys: 'Drag', action: 'move the ship sprite' },
            ],
            status: '',
            hint: 'Drag the ship to move it; the crosshair follows the cursor.',
        });
    }

    private buttonLabel(): string {
        const held = [this.buttons & 1 && 'Left', this.buttons & 2 && 'Right', this.buttons & 4 && 'Middle'].filter(Boolean);

        return held.length ? held.join(' + ') : 'none';
    }

    override update(): void {
        this.deltaX = this.pointer.x - this.previous.x;
        this.deltaY = this.pointer.y - this.previous.y;
        this.previous.x = this.pointer.x;
        this.previous.y = this.pointer.y;

        this.hud.setStatus(
            `x ${Math.round(this.pointer.x)}, y ${Math.round(this.pointer.y)} · Δ ${this.deltaX >= 0 ? '+' : ''}${this.deltaX.toFixed(0)}, ${this.deltaY >= 0 ? '+' : ''}${this.deltaY.toFixed(0)} · buttons: ${this.buttonLabel()} · clicks: ${this.clicks}`,
        );
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.ship);

        this.crosshair.clear();
        this.crosshair.lineWidth = 2;
        this.crosshair.lineColor = new Color(255, 220, 80);
        this.crosshair.drawLine(this.pointer.x - 12, this.pointer.y, this.pointer.x + 12, this.pointer.y);
        this.crosshair.drawLine(this.pointer.x, this.pointer.y - 12, this.pointer.x, this.pointer.y + 12);
        context.render(this.crosshair);
    }
}

app.start(new MouseAndPointerScene());

A draggable sprite with a crosshair that follows the primary pointer.

Pointer to World Pointer Open in Playground View source
import { Application, Color, Graphics, Scene, View } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 12, 20),
    loader: {
        basePath: 'assets/',
    },
});

// The camera continuously pans (a slow figure-eight) and breathes its zoom, so
// the same screen pixel maps to a moving world point every frame. `screenToWorld`
// handles all of that — including the view's viewport rectangle — so we never
// hand-roll the inverse projection. Tap to drop a marker in *world* space; it
// stays pinned to the world as the camera moves over it.
class PointerToWorldScene extends Scene {
    private view!: View;
    private grid!: Graphics;
    private markers!: Graphics;
    private cursor = { x: 0, y: 0 };
    private world = { x: 0, y: 0 };
    private markerWorld: Array<{ x: number; y: number }> = [];
    private elapsed = 0;
    private userZoom = 1;
    private hud!: ReturnType<typeof mountControls>;

    override init(): void {
        const { width, height } = this.app.canvas;

        this.view = new View(width / 2, height / 2, width, height);
        this.grid = new Graphics();
        this.markers = new Graphics();
        this.cursor = { x: width / 2, y: height / 2 };

        // Static world-space grid so the camera motion is visible against it.
        // Extends well beyond the viewport so the panning camera never runs off it.
        this.grid.lineWidth = 1;
        this.grid.lineColor = new Color(60, 66, 82);

        for (let x = -640; x <= width + 640; x += 80) {
            this.grid.drawLine(x, -480, x, height + 480);
        }

        for (let y = -480; y <= height + 480; y += 80) {
            this.grid.drawLine(-640, y, width + 640, y);
        }

        this.app.input.onPointerMove.add(pointer => {
            this.cursor.x = pointer.x;
            this.cursor.y = pointer.y;
        });

        this.app.input.onPointerTap.add(pointer => {
            const world = this.view.screenToWorld(pointer.x, pointer.y, this.app.canvas.width, this.app.canvas.height);

            this.markerWorld.push({ x: world.x, y: world.y });
        });

        // Scroll to nudge a user-controlled zoom that the automatic breath multiplies.
        this.app.input.onMouseWheel.add(offset => {
            this.userZoom = Math.max(0.4, Math.min(3, this.userZoom + (offset.y < 0 ? 0.1 : -0.1)));
        });

        this.hud = mountControls({
            title: 'Pointer to World',
            controls: [
                { keys: 'Move', action: 'read world coordinate' },
                { keys: 'Click', action: 'drop a world-pinned marker' },
                { keys: 'Wheel', action: 'zoom' },
            ],
            status: '',
            hint: 'The camera pans and zooms on its own — markers stay fixed in the world.',
        });
    }

    override update(delta): void {
        const { width, height } = this.app.canvas;

        this.elapsed += delta.seconds;

        // Slow figure-eight pan plus a gentle zoom breath.
        const centerX = width / 2 + Math.sin(this.elapsed * 0.5) * 220;
        const centerY = height / 2 + Math.sin(this.elapsed * 1.0) * 140;

        this.view.setCenter(centerX, centerY);
        this.view.setZoom(this.userZoom * (1 + Math.sin(this.elapsed * 0.35) * 0.25));
        this.view.update(delta.milliseconds);

        // Live world coordinate under the cursor — recomputed every frame because
        // the mapping changes as the camera moves.
        this.world = this.view.screenToWorld(this.cursor.x, this.cursor.y, this.app.canvas.width, this.app.canvas.height);

        this.hud.setStatus(`Screen ${Math.round(this.cursor.x)}, ${Math.round(this.cursor.y)} → World ${this.world.x.toFixed(0)}, ${this.world.y.toFixed(0)} · zoom ${this.view.zoomLevel.toFixed(2)}`);
    }

    override draw(context): void {
        context.backend.clear();
        context.backend.setView(this.view);

        context.render(this.grid);

        // Rebuild markers each frame in their fixed world positions.
        this.markers.clear();
        this.markers.fillColor = new Color(255, 160, 80);

        for (const marker of this.markerWorld) {
            this.markers.drawCircle(marker.x, marker.y, 7);
        }

        // Highlight the live cursor→world point.
        this.markers.fillColor = new Color(120, 230, 255);
        this.markers.drawCircle(this.world.x, this.world.y, 5);

        context.render(this.markers);
        context.backend.setView(null);
    }
}

app.start(new PointerToWorldScene());

Tapping on a zoomed, pannable grid to place markers at the correct world coordinates.

Where to go next

The next chapter, Gamepad, covers the four-slot gamepad system — button and axis listeners, vibration, slot strategies, and per-pad connection lifecycle.