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:
Signal
Fires when
onPointerDown
A pointer presses (mouse button down, finger touches, pen contacts)
onPointerMove
A pointer moves
onPointerUp
A pointer releases
onPointerTap
A pointer releases without significant movement
onPointerSwipe
A pointer releases after moving beyond the distance threshold
onPointerEnter
A pointer enters the canvas boundary
onPointerLeave
A pointer leaves the canvas boundary
onPointerCancel
The browser cancels the pointer (interruption, gesture takeover)
Each signal’s callback receives the Pointer instance:
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:
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.width;});
For most multi-touch use cases, tracking pointer instances by ID via the signal API is simpler:
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 design space (0..app.width × 0..app.height), independent of device pixel ratio or how the canvas is displayed. To map them into scene world coordinates — for placing objects, selecting units, or aiming — pass them through the active view’s screenToWorld, which undoes the camera’s pan / zoom / rotation:
const world = this._view.screenToWorld(pointer.x, pointer.y);// world.x, world.y are now in scene world space
The inverse is worldToScreen(worldX, worldY), and the two round-trip. If the default centered camera is active (no pan/zoom), design space already equals world space, so screenToWorld is the identity and no mapping is needed.
For raw canvas backing-store pixels — e.g. an off-screen render target at a different pixel ratio — the four-argument form screenToWorld(x, y, canvasWidth, canvasHeight) additionally accounts for the viewport rectangle and physical pixel size.
Interactive sprites
A Sprite (or any RenderNode) can be made interactive and draggable directly:
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 // design-space pixel position (0..app.width, 0..app.height)pointer.position // Vector with current x and ypointer.buttons // bitmask: 1=left, 2=right, 4=middlepointer.isPrimary // true for mouse or first finger to touchpointer.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.
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 design-space pixel maps to a moving world point every frame.
// `screenToWorld(x, y)` undoes the camera transform — pointer coordinates are
// already in design space (`0..app.width`) — 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 = this.app.width;
const height = this.app.height;
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.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 = this.app.width;
const height = this.app.height;
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.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.