Pointer Input System
The PointerManager class provides unified input handling for mouse, touch, and pen devices using the modern Pointer Events API. It offers game-friendly features like grid-based coordinate conversion, button state tracking, pressure sensitivity, and event callbacks.
Features
- Universal Input - Works with mouse, touch, and stylus/pen
- Position Tracking - Track pointer position in pixels and grid coordinates
- Button State - Detect button press/release for all buttons (left, right, middle)
- Pointer Type Detection - Identify input device (mouse, touch, pen)
- Pressure Sensitivity - Access pressure data for pen/touch input
- Frame-based Detection -
justPressedandjustReleasedfor single-frame detection - Event Callbacks - Register callbacks for clicks, hover, drag start/end
- Grid Coordinates - Automatic pixel-to-grid conversion
- World Coordinates - Convert to world coordinates with camera offset
- Hover Detection - Check if pointer is over element or specific grid cell
- Drag Tracking - Detect drag operations with delta tracking
Quick Start
import { PointerManager } from "@shaisrc/tty";
// Create pointer manager
const element = document.getElementById("game-canvas")!;
const pointer = new PointerManager(
element,
80, // grid width
24, // grid height
10, // cell width in pixels
10, // cell height in pixels
);
// In game loop
function update() {
// Works with mouse, touch, and pen!
if (pointer.justPressed(0)) {
const grid = pointer.getGridPosition();
const type = pointer.getPointerType(); // "mouse", "touch", or "pen"
console.log(`${type} clicked at grid: ${grid.x}, ${grid.y}`);
}
// Clear frame state (do this once per frame)
pointer.update();
requestAnimationFrame(update);
}
// Cleanup when done
pointer.destroy();Migration from MouseManager
PointerManager is a drop-in replacement for MouseManager:
// Old (still works, but deprecated)
import { MouseManager } from "@shaisrc/tty";
const mouse = new MouseManager(element, 80, 24, 10, 10);
// New (recommended)
import { PointerManager } from "@shaisrc/tty";
const pointer = new PointerManager(element, 80, 24, 10, 10);All methods are identical, plus you get:
- Touch support (taps, swipes, touch drag)
- Pen/stylus support with pressure sensitivity
- Better mobile device compatibility
API Reference
Constructor
new PointerManager(
element: HTMLElement,
gridWidth: number,
gridHeight: number,
cellWidth: number,
cellHeight: number
)Creates a new pointer manager that tracks pointer input on the specified element.
Parameters:
element- HTML element to attach event listeners togridWidth- Width of the grid in cellsgridHeight- Height of the grid in cellscellWidth- Width of each cell in pixelscellHeight- Height of each cell in pixels
Button State
isPressed(button: number): boolean
Check if a pointer button is currently pressed.
Parameters:
button- Button number (0 = left/primary, 1 = middle, 2 = right)
Returns: true if button is currently pressed
if (pointer.isPressed(0)) {
// Primary button (left mouse / touch / pen tip) is pressed
}isLeftPressed(): boolean
Check if left/primary button is currently pressed (works for mouse, touch, and pen).
if (pointer.isLeftPressed()) {
handleClick();
}isRightPressed(): boolean
Check if right button is currently pressed (mouse only).
if (pointer.isRightPressed()) {
showContextMenu();
}isMiddlePressed(): boolean
Check if middle button is currently pressed (mouse only).
if (pointer.isMiddlePressed()) {
startCameraPan();
}justPressed(button: number): boolean
Check if button was just pressed this frame. Returns true only once until update() is called.
Parameters:
button- Button number (0 = left/primary, 1 = middle, 2 = right)
// In game loop
if (pointer.justPressed(0)) {
// Handle single click/tap (won't repeat until released and pressed again)
fireWeapon();
}
pointer.update(); // Clear justPressed statejustReleased(button: number): boolean
Check if button was just released this frame. Returns true only once until update() is called.
if (pointer.justReleased(0)) {
// Handle button release
stopDragging();
}Position Tracking
getPosition(): PointerPosition
Get current pointer position in pixels relative to the element.
Returns: { x: number, y: number } - Pixel coordinates
const pos = pointer.getPosition();
console.log(`Pointer at ${pos.x}px, ${pos.y}px`);getGridPosition(): PointerPosition
Get current pointer position in grid coordinates.
Returns: { x: number, y: number } - Grid cell coordinates
const grid = pointer.getGridPosition();
console.log(`Hovering cell ${grid.x}, ${grid.y}`);getWorldPosition(cameraX: number, cameraY: number): PointerPosition
Convert pointer position to world coordinates using camera offset.
Parameters:
cameraX- Camera X offsetcameraY- Camera Y offset
Returns: { x: number, y: number } - World coordinates
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y);
console.log(`World position: ${world.x}, ${world.y}`);Pointer Type & Pressure
getPointerType(): string
Get the type of pointer device being used.
Returns: "mouse", "touch", "pen", or "" (unknown)
const type = pointer.getPointerType();
if (type === "touch") {
// Show larger touch-friendly buttons
showTouchControls();
} else if (type === "pen") {
// Enable pressure-sensitive drawing
enablePressureDrawing();
}getPressure(): number
Get the pressure of the pointer (0.0 to 1.0).
Returns: Pressure value between 0.0 (no pressure) and 1.0 (maximum pressure)
Note: Most useful for pen/stylus input. Touch typically returns 0.5, mouse returns 0.5 (no pressure data).
if (pointer.getPointerType() === "pen") {
const pressure = pointer.getPressure();
const brushSize = Math.floor(pressure * 10); // 0-10 pixels
drawWithBrush(brushSize);
}Hover Detection
isHovering(): boolean
Check if pointer is currently hovering over the element.
if (pointer.isHovering()) {
renderer.drawText(0, 0, "Pointer over game!", { fg: "yellow" });
}isHoveringCell(x: number, y: number): boolean
Check if pointer is hovering over a specific grid cell.
Parameters:
x- Grid cell X coordinatey- Grid cell Y coordinate
// Highlight hovered cell
for (let y = 0; y < 24; y++) {
for (let x = 0; x < 80; x++) {
if (pointer.isHoveringCell(x, y)) {
renderer.rect(x, y, 1, 1, " ", null, "yellow");
}
}
}Drag Tracking
isDragging(): boolean
Check if currently dragging (left/primary button pressed and moved).
if (pointer.isDragging()) {
const delta = pointer.getDragDelta();
camera.x -= Math.floor(delta.x / cellWidth);
camera.y -= Math.floor(delta.y / cellHeight);
}getDragDelta(): PointerPosition
Get drag distance from start position in pixels.
Returns: { x: number, y: number } - Delta in pixels
const delta = pointer.getDragDelta();
console.log(`Dragged ${delta.x}px, ${delta.y}px`);Event Callbacks
onClick(callback: PointerCallback): void
Register a callback for click/tap events.
Callback receives:
{
pixel: { x: number, y: number }, // Pixel coordinates
grid: { x: number, y: number }, // Grid coordinates
event: PointerEvent // Original DOM event
}pointer.onClick(({ grid, event }) => {
const type = event.pointerType; // "mouse", "touch", or "pen"
console.log(`${type} clicked cell ${grid.x}, ${grid.y}`);
});onHover(callback: PointerCallback): void
Register a callback for pointer move events.
pointer.onHover(({ grid }) => {
highlightedCell = grid;
});onDragStart(callback: PointerCallback): void
Register a callback for drag start (left/primary button pressed).
pointer.onDragStart(({ grid }) => {
dragStartCell = grid;
});onDragEnd(callback: PointerCallback): void
Register a callback for drag end (left/primary button released after drag).
pointer.onDragEnd(({ grid }) => {
selectRegion(dragStartCell, grid);
});Lifecycle Methods
update(): void
Clear frame-based state (justPressed, justReleased). Call once per frame in your game loop.
function gameLoop() {
// Handle input
if (pointer.justPressed(0)) {
handleClick();
}
// Render
renderer.render();
// Clear frame state
pointer.update();
requestAnimationFrame(gameLoop);
}clear(): void
Clear all button states and drag state.
// Reset on level change
pointer.clear();destroy(): void
Remove all event listeners and cleanup. Call when done with the pointer manager.
// Cleanup
pointer.destroy();Common Patterns
Universal Click/Tap Handler
const pointer = new PointerManager(canvas, 80, 24, 10, 10);
pointer.onClick(({ grid, event }) => {
const camera = renderer.getCamera();
const world = {
x: grid.x + camera.x,
y: grid.y + camera.y,
};
// Works with mouse, touch, and pen!
player.moveTo(world.x, world.y);
});Touch-Friendly UI
pointer.onClick(({ grid, event }) => {
if (event.pointerType === "touch") {
// Larger tap targets for touch
const TOUCH_RADIUS = 2;
const target = findNearestButton(grid.x, grid.y, TOUCH_RADIUS);
if (target) target.click();
} else {
// Precise click for mouse
const button = getButtonAt(grid.x, grid.y);
if (button) button.click();
}
});Pressure-Sensitive Drawing (Pen)
pointer.onHover(({ grid, event }) => {
if (event.pointerType === "pen" && pointer.isLeftPressed()) {
const pressure = pointer.getPressure();
const brushSize = Math.floor(1 + pressure * 5); // 1-6 cells
const opacity = 0.3 + pressure * 0.7; // 0.3-1.0
drawBrush(grid.x, grid.y, brushSize, opacity);
}
});Context Menu (Right Click or Long Press)
let touchStartTime = 0;
const LONG_PRESS_MS = 500;
pointer.onDragStart(({ event }) => {
if (event.pointerType === "touch") {
touchStartTime = Date.now();
}
});
pointer.onClick(({ grid, event }) => {
// Right click for mouse
if (event.button === 2) {
event.preventDefault();
showContextMenu(grid.x, grid.y);
}
});
pointer.onDragEnd(({ grid, event }) => {
// Long press for touch
if (event.pointerType === "touch") {
const duration = Date.now() - touchStartTime;
if (duration >= LONG_PRESS_MS) {
showContextMenu(grid.x, grid.y);
}
}
});Drag to Pan (All Devices)
let lastDragPos = { x: 0, y: 0 };
pointer.onDragStart(({ pixel }) => {
lastDragPos = pixel;
});
function update() {
if (pointer.isDragging()) {
const delta = pointer.getDragDelta();
const camera = renderer.getCamera();
// Works with mouse drag, touch drag, or pen drag!
renderer.setCamera(
camera.x - Math.floor(delta.x / 10),
camera.y - Math.floor(delta.y / 10),
);
}
pointer.update();
requestAnimationFrame(update);
}Hover Highlight
function render() {
renderer.clear();
// Draw grid
for (let y = 0; y < 24; y++) {
for (let x = 0; x < 80; x++) {
// Highlight hovered cell
if (pointer.isHoveringCell(x, y)) {
renderer.rect(x, y, 1, 1, "·", "yellow");
} else {
renderer.rect(x, y, 1, 1, "·", "gray");
}
}
}
renderer.render();
}Select Region with Drag
let selectionStart: { x: number; y: number } | null = null;
pointer.onDragStart(({ grid }) => {
selectionStart = grid;
});
pointer.onDragEnd(({ grid }) => {
if (selectionStart) {
const minX = Math.min(selectionStart.x, grid.x);
const maxX = Math.max(selectionStart.x, grid.x);
const minY = Math.min(selectionStart.y, grid.y);
const maxY = Math.max(selectionStart.y, grid.y);
selectUnitsInRegion(minX, minY, maxX, maxY);
}
selectionStart = null;
});
function render() {
// Draw selection preview
if (pointer.isDragging() && selectionStart) {
const current = pointer.getGridPosition();
const minX = Math.min(selectionStart.x, current.x);
const maxX = Math.max(selectionStart.x, current.x);
const minY = Math.min(selectionStart.y, current.y);
const maxY = Math.max(selectionStart.y, current.y);
renderer.box(minX, minY, maxX - minX + 1, maxY - minY + 1, {
style: "dashed",
fg: "cyan",
});
}
}Adaptive UI Based on Input Type
let uiMode: "desktop" | "touch" | "pen" = "desktop";
pointer.onClick(({ event }) => {
const type = event.pointerType;
if (type === "touch") {
uiMode = "touch";
// Larger buttons, simplified controls
renderTouchUI();
} else if (type === "pen") {
uiMode = "pen";
// Enable drawing tools, pressure controls
renderPenUI();
} else {
uiMode = "desktop";
// Standard mouse UI
renderDesktopUI();
}
});Place Objects (All Devices)
const pointer = new PointerManager(canvas, 80, 24, 10, 10);
let selectedTile = "wall";
pointer.onClick(({ grid, event }) => {
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y);
if (event.button === 0 || event.pointerType === "touch") {
// Left click or touch tap
placeTile(world.x, world.y, selectedTile);
} else if (event.button === 2) {
// Right click
eraseTile(world.x, world.y);
}
});
// Show preview
function render() {
if (pointer.isHovering()) {
const grid = pointer.getGridPosition();
// Draw preview at grid position
renderer.drawText(grid.x, grid.y, getTileChar(selectedTile), {
fg: "white",
bg: "gray",
});
}
}Tooltip on Hover
let tooltipText = "";
pointer.onHover(({ grid }) => {
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y);
const entity = getEntityAt(world.x, world.y);
if (entity) {
tooltipText = `${entity.name} (HP: ${entity.hp}/${entity.maxHp})`;
} else {
tooltipText = "";
}
});
function render() {
renderer.clear();
renderGame();
// Draw tooltip
if (tooltipText && pointer.isHovering()) {
const pos = pointer.getGridPosition();
const panel = renderer.panel(
pos.x + 1,
pos.y + 1,
tooltipText.length + 2,
3,
{
title: "Info",
border: "single",
fg: "yellow",
},
);
panel.text(1, 1, tooltipText);
}
renderer.render();
}Integration Examples
With Renderer and Camera
import { Renderer, CanvasTarget, PointerManager } from "@shaisrc/tty";
const canvas = document.getElementById("game") as HTMLCanvasElement;
const target = new CanvasTarget(canvas, { width: 80, height: 24 });
const renderer = new Renderer(target);
const pointer = new PointerManager(canvas, 80, 24, 10, 10);
// Click/tap to move camera
pointer.onClick(({ grid }) => {
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y);
renderer.follow(world.x, world.y, 80, 24);
});
function gameLoop() {
renderer.clear();
// Highlight hovered cell
if (pointer.isHovering()) {
const grid = pointer.getGridPosition();
renderer.rect(grid.x, grid.y, 1, 1, " ", null, "yellow");
}
renderer.render();
pointer.update();
requestAnimationFrame(gameLoop);
}
gameLoop();Complete RTS-style Input (Desktop + Mobile)
const pointer = new PointerManager(canvas, 80, 24, 10, 10);
const keyboard = new KeyboardManager();
let selectedUnits: Unit[] = [];
let selectionBox: { start: Point; end: Point } | null = null;
// Click/tap to select
pointer.onClick(({ grid, event }) => {
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y);
// Shift key or multi-touch for multi-select
if (!event.shiftKey) {
selectedUnits = [];
}
const unit = getUnitAt(world.x, world.y);
if (unit) {
selectedUnits.push(unit);
}
});
// Drag to select multiple
pointer.onDragStart(({ grid }) => {
selectionBox = { start: grid, end: grid };
});
pointer.onDragEnd(({ grid }) => {
if (selectionBox) {
const units = getUnitsInBox(selectionBox.start, grid);
selectedUnits = units;
selectionBox = null;
}
});
// Right click or long press to move
pointer.onClick(({ grid, event }) => {
if (event.button === 2 && selectedUnits.length > 0) {
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y);
selectedUnits.forEach((unit) => unit.moveTo(world.x, world.y));
}
});
function update() {
// Arrow keys pan (keyboard only)
const camera = renderer.getCamera();
if (keyboard.isPressed("ArrowLeft")) camera.x--;
if (keyboard.isPressed("ArrowRight")) camera.x++;
if (keyboard.isPressed("ArrowUp")) camera.y--;
if (keyboard.isPressed("ArrowDown")) camera.y++;
pointer.update();
keyboard.update();
requestAnimationFrame(update);
}Touch-Specific Tips
Prevent Default Touch Behaviors
// Prevent pinch-zoom, double-tap zoom, etc.
canvas.style.touchAction = "none";
// Or in CSS:
// #game-canvas {
// touch-action: none;
// }Touch vs Mouse Detection
let hasTouchSupport = false;
pointer.onClick(({ event }) => {
if (event.pointerType === "touch") {
hasTouchSupport = true;
// Adjust UI for touch
showMobileControls();
}
});Swipe Detection
let swipeStart = { x: 0, y: 0, time: 0 };
const SWIPE_THRESHOLD = 50; // pixels
const SWIPE_MAX_TIME = 300; // ms
pointer.onDragStart(({ pixel, event }) => {
if (event.pointerType === "touch") {
swipeStart = { x: pixel.x, y: pixel.y, time: Date.now() };
}
});
pointer.onDragEnd(({ pixel, event }) => {
if (event.pointerType === "touch") {
const duration = Date.now() - swipeStart.time;
const dx = pixel.x - swipeStart.x;
const dy = pixel.y - swipeStart.y;
if (duration < SWIPE_MAX_TIME) {
if (Math.abs(dx) > SWIPE_THRESHOLD) {
if (dx > 0) onSwipeRight();
else onSwipeLeft();
} else if (Math.abs(dy) > SWIPE_THRESHOLD) {
if (dy > 0) onSwipeDown();
else onSwipeUp();
}
}
}
});Best Practices
Always Call update()
Call pointer.update() once per frame to clear justPressed and justReleased states:
function gameLoop() {
handleInput();
updateGame();
render();
pointer.update(); // ← Important!
requestAnimationFrame(gameLoop);
}Use justPressed for Single Actions
Use justPressed instead of isPressed for actions that should only trigger once per click/tap:
// ✅ Good - fires once per click/tap
if (pointer.justPressed(0)) {
shoot();
}
// ❌ Bad - fires every frame while held
if (pointer.isPressed(0)) {
shoot(); // Spam!
}Check Pointer Type for Adaptive UI
pointer.onClick(({ event }) => {
if (event.pointerType === "touch") {
// Touch: larger buttons, simplified controls
buttonSize = 3;
} else {
// Mouse/pen: precise controls
buttonSize = 1;
}
});World vs Grid vs Pixel Coordinates
Be clear about which coordinate system you're using:
// Pixel: Raw pointer position
const pixel = pointer.getPosition(); // { x: 145, y: 230 }
// Grid: Screen cell coordinates
const grid = pointer.getGridPosition(); // { x: 14, y: 23 }
// World: Grid + camera offset
const camera = renderer.getCamera();
const world = pointer.getWorldPosition(camera.x, camera.y); // { x: 114, y: 123 }Cleanup on Destroy
Always call destroy() when done to prevent memory leaks:
// When switching scenes
function exitGame() {
pointer.destroy();
keyboard.destroy();
renderer.destroy();
}Combine with Keyboard for Desktop
// Shift + click for multi-select (desktop)
pointer.onClick(({ grid }) => {
if (keyboard.isPressed("Shift")) {
addToSelection(grid);
} else {
selectSingle(grid);
}
});
// Ctrl + drag for duplicate (desktop)
pointer.onDragEnd(({ grid }) => {
if (keyboard.isPressed("Control")) {
duplicateObject(grid);
} else {
moveObject(grid);
}
});Set Touch Action CSS
For proper touch handling, set touch-action: none on your canvas:
#game-canvas {
touch-action: none; /* Prevents default touch behaviors */
}Or in JavaScript:
canvas.style.touchAction = "none";Performance Tips
- Pointer events are well-optimized and work across all modern browsers
- No performance penalty compared to separate mouse/touch handlers
- Use
touchAction: noneCSS to prevent browser from processing touch gestures - Avoid heavy calculations in
onHovercallbacks (called on every move)
Browser Support
Pointer Events API is supported in:
- ✅ Chrome/Edge 55+
- ✅ Firefox 59+
- ✅ Safari 13+
- ✅ All modern mobile browsers
This covers >95% of users as of 2026.
