World space, canvas space, and how the active view maps between them — plus camera movement, zoom, and split rendering.
Coordinates and views
ExoJS keeps two coordinate systems in mind at once. Drawables are positioned in world space — the coordinate system you write game logic in. The renderer paints into canvas space — pixels on the actual canvas the user sees. A View sits between the two and handles the mapping.
Understanding this split makes camera follow, zoom, split-screen, and HUD overlays straightforward. Confusing them is the source of most “the position is wrong” bugs.
World space
When you call sprite.setPosition(400, 300), you’re setting a position in world space. The numbers you choose are the same numbers your game logic operates on — distances, velocities, hit boxes. They have nothing to do with the user’s screen.
In a typical 2D project, world space has the X axis pointing right and the Y axis pointing down (matching browser conventions). The origin is wherever you put it; many projects use (0, 0) as the world’s center, others put it at the top-left of the playable area.
Canvas space
Canvas space is what the GPU paints into: pixels of the application’s canvas. (0, 0) is the top-left, (canvas.width, canvas.height) is the bottom-right. Mouse events arrive in canvas space — when the user clicks at pixel (245, 110), that’s a canvas-space coordinate.
If you placed every sprite at its world position and the camera never moved, world space and canvas space would line up exactly. The moment you move the camera, scale the canvas, or render to a sub-region, the two diverge.
The active view
Every render backend exposes a view property. The view defines how world space maps onto the canvas:
view.center — the world-space point at the center of the canvas.
view.size — how much world space fits inside the canvas.
view.rotation — the camera’s rotation in degrees.
view.viewport — which sub-region of the canvas the view paints into (default: the whole canvas).
Set the center to (400, 300) and the size to (800, 600), and the canvas shows the world from (0, 0) to (800, 600). Move the center to (400, 600) and the camera scrolls down by 300 pixels’ worth of world.
draw(context) {
context.camera.setCenter(400, 300);
context.camera.resize(800, 600);
context.backend.clear();
context.render(this.world);
}
You don’t usually configure the view by hand — it tracks the canvas size automatically. Reach for setCenter, move, setZoom, and setRotation when you need camera behavior. Configure the camera through the context passed to draw, using context.camera.
Following an object
The view supports following a target. The target can be any object with x and y properties — typically a scene node, but a plain {x, y} object works too:
draw(context) {
context.camera.follow(this.player);
context.backend.clear();
context.render(this.world);
}
Following keeps the view centered on the target while the scene renders. Call view.clearFollow() to detach. If you want smoothing, keep a small camera target object and move it toward the player in update, then follow that target instead of the player directly.
Zooming
The setZoom(z) method scales how much world fits inside the view. setZoom(2) means the view shows half as much world as it normally would (everything looks twice as big). setZoom(0.5) does the opposite (everything looks zoomed out). The zoomIn(amount) and zoomOut(amount) helpers adjust relative to the current zoom.
init() {
this.zoom = 1;
this.inputs.onTrigger(Keyboard.Plus, () => {
this.zoom += 0.1;
});
}
draw(context) {
context.camera.setZoom(this.zoom);
context.backend.clear();
context.render(this.world);
}
Common pitfalls
A handful of misalignments come up regularly:
- Hardcoded canvas-relative positions. Setting a sprite to
(400, 300) because that “looks centered for an 800×600 canvas” breaks the moment the canvas resizes. Use the active view’s center, the canvas size at runtime, or anchor-aware containers instead.
- Anchor confusion. A sprite’s position is the world-space location of its anchor. The default anchor is the top-left, so
setPosition(400, 300) puts the top-left of the sprite at (400, 300), not its middle. Call setAnchor(0.5) to pivot at the center.
- Pointer events without view conversion. Pointer events arrive in canvas space. If your camera has moved, you need to convert them back to world space before testing whether the click hit a world-space object. The world-vs-screen-coords example below shows the conversion.
- HUD inside the world. A health bar should not move when the camera moves. Put HUD elements in a separate container that you render after
context.render(world), with their positions in canvas space.
Multiple views
The active view can be swapped on every frame. Used together with viewports, this gives you split-screen, picture-in-picture, and minimap rendering — render the world once with one view configuration into one viewport, then again with a different configuration into another:
draw(context) {
context.backend.clear();
context.camera.viewport = this.leftHalf;
context.camera.setCenter(this.player1.x, this.player1.y);
context.render(this.world);
context.camera.viewport = this.rightHalf;
context.camera.setCenter(this.player2.x, this.player2.y);
context.render(this.world);
}
The same scene graph rendered with two different views, into two halves of the canvas — one tree, two cameras. If you continue rendering HUD elements or additional scenes afterward, reset the viewport/view state before those passes so later rendering does not inherit the split-screen configuration.
Examples
import { Application, Color, Graphics, Keyboard, Scene, Text, View } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class CameraViewScene extends Scene {
private camera!: View;
private world!: Graphics;
private overlay!: Text;
private moveX = 0;
private moveY = 0;
private zoom = 0;
override init(): void {
const { width, height } = this.app.canvas;
this.camera = new View(0, 0, width, height);
this.world = new Graphics();
this.world.lineWidth = 2;
this.world.lineColor = Color.darkGray;
for (let x = -1200; x <= 1200; x += 120) {
this.world.drawLine(x, -900, x, 900);
}
for (let y = -900; y <= 900; y += 120) {
this.world.drawLine(-1200, y, 1200, y);
}
this.overlay = new Text('WASD pan, Q/E zoom', { fillColor: Color.white, fontSize: 16 });
this.overlay.setPosition(12, 12);
this.inputs.onActive(Keyboard.A, () => {
this.moveX = -1;
});
this.inputs.onStop(Keyboard.A, () => {
if (this.moveX < 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.D, () => {
this.moveX = 1;
});
this.inputs.onStop(Keyboard.D, () => {
if (this.moveX > 0) this.moveX = 0;
});
this.inputs.onActive(Keyboard.W, () => {
this.moveY = -1;
});
this.inputs.onStop(Keyboard.W, () => {
if (this.moveY < 0) this.moveY = 0;
});
this.inputs.onActive(Keyboard.S, () => {
this.moveY = 1;
});
this.inputs.onStop(Keyboard.S, () => {
if (this.moveY > 0) this.moveY = 0;
});
this.inputs.onActive(Keyboard.Q, () => {
this.zoom = 1;
});
this.inputs.onStop(Keyboard.Q, () => {
if (this.zoom > 0) this.zoom = 0;
});
this.inputs.onActive(Keyboard.E, () => {
this.zoom = -1;
});
this.inputs.onStop(Keyboard.E, () => {
if (this.zoom < 0) this.zoom = 0;
});
}
override update(delta): void {
this.camera.move(this.moveX * 420 * delta.seconds, this.moveY * 420 * delta.seconds);
this.camera.setZoom(Math.max(0.25, this.camera.zoomLevel + this.zoom * 0.75 * delta.seconds));
}
override draw(context): void {
context.backend.clear();
context.backend.setView(this.camera);
context.render(this.world);
context.backend.setView(null);
context.render(this.overlay);
}
}
app.start(new CameraViewScene());
Pan and zoom the view across a scene that’s larger than the canvas.
import { Application, Color, Graphics, Keyboard, Scene, Sprite, Texture, View } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class SplitScreenScene extends Scene {
private texture!: Texture;
private leftView!: View;
private rightView!: View;
private divider!: Graphics;
private leftPlayer!: Sprite;
private rightPlayer!: Sprite;
private move = {
a: 0,
d: 0,
w: 0,
s: 0,
left: 0,
right: 0,
up: 0,
down: 0,
};
override async load(loader): Promise<void> {
this.texture = await loader.load(Texture, 'image/ship-a.png');
}
override init(): void {
const { width, height } = this.app.canvas;
this.leftView = new View(0, 0, width / 2, height);
this.leftView.viewport.set(0, 0, 0.5, 1);
this.rightView = new View(0, 0, width / 2, height);
this.rightView.viewport.set(0.5, 0, 0.5, 1);
this.divider = new Graphics();
this.divider.fillColor = Color.white;
this.divider.drawRectangle(width / 2 - 1, 0, 2, height);
this.leftPlayer = new Sprite(this.texture)
.setAnchor(0.5)
.setPosition(-160, 0)
.setTint(new Color(120, 190, 255));
this.rightPlayer = new Sprite(this.texture)
.setAnchor(0.5)
.setPosition(160, 0)
.setTint(new Color(255, 180, 120));
this.inputs.onActive(Keyboard.A, () => {
this.move.a = 1;
});
this.inputs.onStop(Keyboard.A, () => {
this.move.a = 0;
});
this.inputs.onActive(Keyboard.D, () => {
this.move.d = 1;
});
this.inputs.onStop(Keyboard.D, () => {
this.move.d = 0;
});
this.inputs.onActive(Keyboard.W, () => {
this.move.w = 1;
});
this.inputs.onStop(Keyboard.W, () => {
this.move.w = 0;
});
this.inputs.onActive(Keyboard.S, () => {
this.move.s = 1;
});
this.inputs.onStop(Keyboard.S, () => {
this.move.s = 0;
});
this.inputs.onActive(Keyboard.Left, () => {
this.move.left = 1;
});
this.inputs.onStop(Keyboard.Left, () => {
this.move.left = 0;
});
this.inputs.onActive(Keyboard.Right, () => {
this.move.right = 1;
});
this.inputs.onStop(Keyboard.Right, () => {
this.move.right = 0;
});
this.inputs.onActive(Keyboard.Up, () => {
this.move.up = 1;
});
this.inputs.onStop(Keyboard.Up, () => {
this.move.up = 0;
});
this.inputs.onActive(Keyboard.Down, () => {
this.move.down = 1;
});
this.inputs.onStop(Keyboard.Down, () => {
this.move.down = 0;
});
}
override update(delta): void {
const speed = 300 * delta.seconds;
this.leftPlayer.move((this.move.d - this.move.a) * speed, (this.move.s - this.move.w) * speed);
this.rightPlayer.move((this.move.right - this.move.left) * speed, (this.move.down - this.move.up) * speed);
this.leftView.setCenter(this.leftPlayer.position.x, this.leftPlayer.position.y);
this.rightView.setCenter(this.rightPlayer.position.x, this.rightPlayer.position.y);
}
override draw(context): void {
context.backend.clear();
context.backend.setView(this.leftView);
context.render(this.leftPlayer);
context.render(this.rightPlayer);
context.backend.setView(this.rightView);
context.render(this.leftPlayer);
context.render(this.rightPlayer);
context.backend.setView(null);
context.render(this.divider);
}
}
app.start(new SplitScreenScene());
Two players, one world, two viewports.
import { Application, Color, Graphics, Scene, Sprite, Texture, View } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class PictureInPictureScene extends Scene {
private mainView!: View;
private pipView!: View;
private sprite!: Sprite;
private velocity = 220;
private frame!: Graphics;
override async load(loader): Promise<void> {
this.sprite = new Sprite(await loader.load(Texture, 'image/ship-a.png'));
}
override init(): void {
const { width, height } = this.app.canvas;
this.mainView = new View(0, 0, width, height);
this.pipView = new View(0, 0, width * 0.3, height * 0.3);
this.pipView.viewport.set(0.68, 0.04, 0.28, 0.28);
this.pipView.setZoom(2.2);
this.sprite.setAnchor(0.5).setPosition(-280, 0);
this.frame = new Graphics();
this.frame.lineWidth = 3;
this.frame.lineColor = Color.white;
this.frame.drawRectangle(width * 0.68, height * 0.04, width * 0.28, height * 0.28);
}
override update(delta): void {
this.sprite.move(this.velocity * delta.seconds, 0);
if (this.sprite.position.x > 320 || this.sprite.position.x < -320) {
this.velocity *= -1;
}
this.pipView.follow(this.sprite, { lerp: 1 });
}
override draw(context): void {
context.backend.clear();
context.backend.setView(this.mainView);
context.render(this.sprite);
context.backend.setView(this.pipView);
context.render(this.sprite);
context.backend.setView(null);
context.render(this.frame);
}
}
app.start(new PictureInPictureScene());
A second view rendered into a small region of the canvas — useful for minimaps and replay overlays.
import { Application, Color, Graphics, Scene, Text, View } from '@codexo/exojs';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
class WorldScreenScene extends Scene {
private view!: View;
private grid!: Graphics;
private markers!: Graphics;
private text!: Text;
private pointer = { x: 0, y: 0 };
override init(): void {
const { width, height } = this.app.canvas;
this.view = new View(260, 160, width, height);
this.grid = new Graphics();
this.markers = new Graphics();
this.text = new Text('', { fillColor: Color.white, fontSize: 16 });
this.grid.lineWidth = 1;
this.grid.lineColor = new Color(60, 60, 60);
for (let x = -200; x <= 1200; x += 100) {
this.grid.drawLine(x, -200, x, 1000);
}
for (let y = -200; y <= 1000; y += 100) {
this.grid.drawLine(-200, y, 1200, y);
}
this.app.input.onPointerMove.add(pointer => {
this.pointer = { x: pointer.x, y: pointer.y };
});
this.app.input.onPointerTap.add(pointer => {
const world = this.toWorld(pointer.x, pointer.y);
this.markers.fillColor = new Color(255, 220, 80);
this.markers.drawCircle(world.x, world.y, 8);
});
}
override draw(context): void {
const world = this.toWorld(this.pointer.x, this.pointer.y);
this.text.text = `screen: ${this.pointer.x | 0}, ${this.pointer.y | 0}\nworld: ${world.x | 0}, ${world.y | 0}`;
this.text.setPosition(12, 12);
context.backend.clear();
context.backend.setView(this.view);
context.render(this.grid);
context.render(this.markers);
context.backend.setView(null);
context.render(this.text);
}
private toWorld(screenX, screenY): { x: number; y: number } {
const width = this.app.canvas.width;
const height = this.app.canvas.height;
const clipX = (screenX / width) * 2 - 1;
const clipY = 1 - (screenY / height) * 2;
const inverse = this.view.getInverseTransform();
return {
x: inverse.a * clipX + inverse.b * clipY + inverse.x,
y: inverse.c * clipX + inverse.d * clipY + inverse.y,
};
}
}
app.start(new WorldScreenScene());
Click anywhere on the canvas; the example shows the same point in both coordinate systems and how to convert between them.
Where to go next
The next chapter, Loading and resources, covers the asset pipeline — how the loader gets textures, audio, and JSON into your scene before init runs.