Guide

Guide Recipes Split screen

Split screen

Render multiple viewpoints in a shared scene timeline.

Intermediate ~2 min read

Split screen

Split screen renders the same scene from two (or more) camera viewpoints into separate regions of the canvas. The recipe uses multiple View instances with different viewport rectangles, renders the shared scene contents once per view, and draws a divider between the regions.

Approach

Create two View objects. Each gets its own viewport rectangle — the left view covers the left half of the canvas, the right view covers the right half. In draw, set the backend’s active view, render the shared world, swap views, render again:

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

    // Left player view — left half of screen
    this._leftView = new View(0, 0, width / 2, height);
    this._leftView.viewport.set(0, 0, 0.5, 1);

    // Right player view — right half of screen
    this._rightView = new View(0, 0, width / 2, height);
    this._rightView.viewport.set(0.5, 0, 0.5, 1);

    // White divider line between views
    this._divider = new Graphics();
    this._divider.fillColor = Color.white;
    this._divider.drawRectangle(width / 2 - 1, 0, 2, height);

    // Shared world objects — both views see these
    this._leftPlayer = new Sprite(loader.get(Texture, 'hero'))
        .setAnchor(0.5)
        .setTint(new Color(120, 190, 255));
    this._rightPlayer = new Sprite(loader.get(Texture, 'hero'))
        .setAnchor(0.5)
        .setTint(new Color(255, 180, 120));
}

update(delta) {
    // Move players independently...
    this._leftPlayer.move(/* WASD */);
    this._rightPlayer.move(/* Arrows */);

    // Each view follows its player
    this._leftView.setCenter(this._leftPlayer.position.x, this._leftPlayer.position.y);
    this._rightView.setCenter(this._rightPlayer.position.x, this._rightPlayer.position.y);
}

draw(context) {
    context.backend.clear();

    // Render left view
    context.render(this._leftPlayer, { view: this._leftView });
    context.render(this._rightPlayer, { view: this._leftView });
    // ... world objects ...

    // Render right view
    context.render(this._leftPlayer, { view: this._rightView });
    context.render(this._rightPlayer, { view: this._rightView });
    // ... same world objects ...

    // Divider renders in default camera space
    context.render(this._divider);
}

The viewport values are normalised 0..1 fractions of the render target size. viewport.set(0, 0, 0.5, 1) means the left view occupies x: 0..50%, y: 0..100% of the canvas. viewport.set(0.5, 0, 0.5, 1) means the right view occupies x: 50%..100%, y: 0..100%.

Shared scene, multiple views

The scene renders twice — once per view. Sprite positions are in world space; changing the view changes which portion of world space is visible on screen. The same context.render(this._leftPlayer, { view }) call produces different on-screen positions in each view because the view transform differs.

This means you can have objects visible in both views (a shared flag, a power-up both players compete for) or objects only rendered in one view (a minimap as a child of one player’s container). The choice is per-object and per-draw call.

Input per player

Two players on one keyboard means binding different key sets to each player — WASD for player 1, arrow keys for player 2 — both registered via the same scene’s this.inputs. For gamepad co-op, bind each pad slot to a different player:

const pad0 = this.app.input.getGamepad(0);
pad0.onActive(GamepadAxis.LeftStickX, v => { this._player1.move.x = v; });

const pad1 = this.app.input.getGamepad(1);
pad1.onActive(GamepadAxis.LeftStickX, v => { this._player2.move.x = v; });

Four split-screen views with four gamepad slots is the same pattern extended — one view per player, one pad per slot, one shared scene rendered four times.

Constraints

  • Each additional view doubles the per-frame render cost for the shared scene. Two views = two full scene renders. Four views = four. The viewport scissor prevents pixel work outside each region, but the draw calls and vertex processing still happen.
  • Scissor rects are set automatically from the view’s viewport. Objects partially crossing the divider line are clipped correctly — the left view clips its right edge, the right view clips its left edge.
  • The divider is drawn without a view override to render in the default camera space, not any player’s world view.

Examples

Multi View Split Screen Keyboard Open in Playground View source
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 on one keyboard — WASD controls the blue bunny (left view), arrow keys control the orange bunny (right view). Each view follows its player.

Where to go next

The next recipe, Audio-reactive scene, covers how to drive visuals, particles, and camera effects from live audio analysis.