Skip to main content

Excalibur v1.0.0 Released!

· 31 min read
Erik Onarheim
Maintainer of Excalibur

It's finally happening! Excalibur.js, has been a labor of love for thirteen years! And we are finally here at v1!

sh
npm install [email protected]
sh
npm install [email protected]

arthur pixel art

What does v1 mean?​

Not much, it's basically been v1 for a while. Excalibur been relatively stable with low bug reports, all our internal goals have been met, AND people are making commerical games on Steam and Itch.io.

Starting with v1, we'll be following semantic versioning as best we can and breaks are considered bugs and will be patched or feature flagged. As a result v1 does have some breaks (but it's mostly just api names) that have been batched up for a while, but we have codemods to make this painless.

On Steam​

Speaking of Steam... here are some Excalibur games you should check out. If your Steam (or other platform) game runs on Excalibur and is not here, tell me!

  • Now THAT'S a Big Dragon! by Cotovelo Games, released August 2026. An idle game where you recruit an ever-growing army of heroes, stack upgrades, and watch the numbers go up as they chip away at a colossal dragon.
  • A Nibble Tale by Maxouhell Studios, in Early Access since August 2026. A pixel-art top-down action roguelite: the rats stole your little brother's snack, and Pinpin the rabbit is going to get it back.
  • Estelle Igni: To Light Fires in the Night Sky by Claire Scott, coming soon, wishlist it now. A JRPG of eternal night, blending techniques from thirteen classes to uncover the rifts consuming the stars.

On Itch and the Web​

New to Excalibur?​

Excalibur is the friendly 2D TypeScript game engine for the web, no dependencies, free and open source BSD-2 forever.

Support for popular tools:

Sweet New Features in v1​

  • Performance Improvements
  • 2D Lighting
  • Pause System
  • Material/Postprocessor Shader Pipelines
  • Serialization API
  • Sound Manager API
  • Sound Mixing API
    • Layering/Spatial audio
  • Excalibur CLI Tool

Performance Improvements​

A lot of the improvements have been made by removing object allocation from the hot loop, pooling frequently used objects, scratch objects, and clever data structures.

We especially spent time in the collision system to improve performance drastically since v0.32.0

TODO ERIK IS TIRED, HE WILL PUT CHART HERE. TRUST ME IT'S BETTER.

So we don't lose sight of our hard won gains, we now have a excalibur-benchmark that runs on PRs

2D Lighting​

This was the most requested feature by far. Justin spent a lot of time prototyping (and re-prototyping) different lighting concepts until we settled on the one we liked. He deserves most of the credit for this feature.

We have ambient lights, point lights, cone lights, and occluders!

typescript
const game = new ex.Engine({
lighting: {
enabled: true,
ambientIntensity: 0.08,
ambientColor: ex.Color.fromRGB(70, 70, 160) // moonlit blue
}
});
// Darkness covers the screen (or a rectangular "room" with width/height)
const night = new ex.Actor();
night.addComponent(new ex.DarknessComponent({ color: ex.Color.fromRGB(0, 0, 12), intensity: 0.92 }));
// Point lights can flicker
const torch = new ex.Actor({ pos: ex.vec(90, 70) });
torch.addComponent(
new ex.PointLightComponent({
color: ex.Color.fromRGB(255, 170, 60),
radius: 160,
flicker: { frequency: 3, amplitude: 0.2 }
})
);
// Cone lights are flashlights, aim them with `direction`
const flashlight = new ex.ConeLightComponent({ radius: 320, angle: Math.PI / 5, softness: 0.35 });
// Anything with an occluder casts a shadow
crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 48, height: 48 } }));
typescript
const game = new ex.Engine({
lighting: {
enabled: true,
ambientIntensity: 0.08,
ambientColor: ex.Color.fromRGB(70, 70, 160) // moonlit blue
}
});
// Darkness covers the screen (or a rectangular "room" with width/height)
const night = new ex.Actor();
night.addComponent(new ex.DarknessComponent({ color: ex.Color.fromRGB(0, 0, 12), intensity: 0.92 }));
// Point lights can flicker
const torch = new ex.Actor({ pos: ex.vec(90, 70) });
torch.addComponent(
new ex.PointLightComponent({
color: ex.Color.fromRGB(255, 170, 60),
radius: 160,
flicker: { frequency: 3, amplitude: 0.2 }
})
);
// Cone lights are flashlights, aim them with `direction`
const flashlight = new ex.ConeLightComponent({ radius: 320, angle: Math.PI / 5, softness: 0.35 });
// Anything with an occluder casts a shadow
crate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 48, height: 48 } }));

Move the mouse to aim the knight's lantern, click to drop more lights. The stones are occluders, the slimes are occluders and green lights at the same time, and a spider is hiding in the dark:

Lighting is opt-in because the overlay is rasterized with the 2D Canvas API and re-uploaded to the GPU every frame, which has a cost. Pass a LightingConfig object to tune the ambient floor, shadow opacity, culling padding, and more, or add a LightingSystem to just the scenes that need it.

Pause System​

Ever want to pause a game? Well now you can without stopping the game loop that drives your UI/UX. Add or configure ex.PauseComponent's on your entities or actors.

Scenes can pause() and resume(). Every Actor carries a PauseComponent by default, canPause: false opts an entity out so menus, HUDs, and pause screens keep running (a ScreenElement defaults to canPause: false). Read actor.isPaused anywhere you need to branch. Read more in the pause docs.

Material/Postprocessor Shader Pipelines​

Before v1 you could only have 1 shader on your Actor materials or on the Screen postprocessors. Now you can compose shaders together in sequence making it much easier to acheive composible and re-usable effects. This enables a cheap way to implement Blur, Glow, and Bloom effects by upsampling and downsampling in between shader passes.

typescript
const graphicsContext = game.graphicsContext;
// Per graphic: `padding` gives the halo room outside the sprite's quad
const glow = new ex.GlowEffect({ graphicsContext, color: ex.Color.Cyan, strength: 3 });
actor.graphics.material = new ex.Material({ graphicsContext, passes: glow, padding: 24 });
// Fullscreen
game.graphicsContext.addPostProcessor(
new ex.ShaderPipelinePostProcessor({
passes: new ex.BloomEffect({ graphicsContext, threshold: 0.5, intensity: 1.2 })
})
);
// Settings are live
glow.intensity = 0.5;
typescript
const graphicsContext = game.graphicsContext;
// Per graphic: `padding` gives the halo room outside the sprite's quad
const glow = new ex.GlowEffect({ graphicsContext, color: ex.Color.Cyan, strength: 3 });
actor.graphics.material = new ex.Material({ graphicsContext, passes: glow, padding: 24 });
// Fullscreen
game.graphicsContext.addPostProcessor(
new ex.ShaderPipelinePostProcessor({
passes: new ex.BloomEffect({ graphicsContext, threshold: 0.5, intensity: 1.2 })
})
);
// Settings are live
glow.intensity = 0.5;

Click to toggle the fullscreen bloom:

Under the hood these are built on new public primitives: ex.Framebuffer (a render destination that is also a texture source), ex.ShaderPass (one fragment shader applied to an image), and ex.ShaderPipeline (a linear chain with per-pass scaling). Anything implementing process(source, destination) can be a pipeline, so custom non-linear graphs are fair game. The pass factories createBlurPasses and createGlowPasses are exported for splicing into your own chains.

The glsl tag and writing your own pipelines​

One foot-gun we've heard about is Excalibur's rendering is premultiplied-alpha. The new ex.glsl tagged template literal fixes that by detecting if your fragment is authored in a straight-alpha space and updating it accordingly. The tag also adds the annoying glsl #version es 300 and precision mediump float boilerplate.

Pass a list of fragments as passes and they run in order. Add a fragmentSource too and it becomes the final composite, with the pipeline output bound as u_graphic and the untouched graphic as u_original. Uniforms are now a plain dictionary on the material that reaches every pass:

typescript
const material = new ex.Material({
graphicsContext,
passes: [wobble, dissolve],
fragmentSource: composite,
uniforms: { u_amount: 0.01, u_dissolve: 0.0 }
});
// no shader binding, no trySetUniform*, just assign
material.uniforms.u_dissolve = 0.5;
typescript
const material = new ex.Material({
graphicsContext,
passes: [wobble, dissolve],
fragmentSource: composite,
uniforms: { u_amount: 0.01, u_dissolve: 0.0 }
});
// no shader binding, no trySetUniform*, just assign
material.uniforms.u_dissolve = 0.5;

Move the mouse left and right to dissolve the sword, up and down to wobble it:

Material.update() now hands your callback a MaterialContext with uniforms, shaders, and shadersByName instead of a single composite shader. Materials are also enumerable through game.graphicsContext.materials, each with an id and readable sources, so tooling like the browser dev tools extension can inspect them.

Serialization API​

We can now serialize built in excalibur types (and developer provided ones) to JSON and back. This is great for communicating over the wire for multiplayer server situations and for saving games to disk (or browser localstorage).

typescript
ex.Serializer.init(); // registers the built-in components
ex.Serializer.registerGraphics({ gem: gemGraphic, hero: heroGraphic });
ex.Serializer.registerComponent(LootComponent);
ex.Serializer.registerCustomActors([Gem, Hero]);
// save: everything tagged 'saveable' becomes JSON
const actors = scene.actors.filter((a) => a.hasTag('saveable'));
localStorage.setItem('save', JSON.stringify(actors.map((a) => ex.Serializer.serializeActor(a))));
// load: actors come back as Gem and Hero instances with their components restored
for (const data of JSON.parse(localStorage.getItem('save')!)) {
scene.add(ex.Serializer.deserializeActor(data)!);
}
typescript
ex.Serializer.init(); // registers the built-in components
ex.Serializer.registerGraphics({ gem: gemGraphic, hero: heroGraphic });
ex.Serializer.registerComponent(LootComponent);
ex.Serializer.registerCustomActors([Gem, Hero]);
// save: everything tagged 'saveable' becomes JSON
const actors = scene.actors.filter((a) => a.hasTag('saveable'));
localStorage.setItem('save', JSON.stringify(actors.map((a) => ex.Serializer.serializeActor(a))));
// load: actors come back as Gem and Hero instances with their components restored
for (const data of JSON.parse(localStorage.getItem('save')!)) {
scene.add(ex.Serializer.deserializeActor(data)!);
}

Plugin API​

Inspired by the Bevy plugin system, we now have a one stop abstraction to deeply configure the internals of the engine. In particular you may be interested in installing an ECS system, custom renderer, pre/post lifecycle hooks all with one type!

typescript
const engine = new ex.Engine({
plugins: [new MyCoolPlugin()]
});
typescript
const engine = new ex.Engine({
plugins: [new MyCoolPlugin()]
});

Auto Save Plugin​

The Serializer and the plugin hooks compose. A scene's lifecycle already marks the right moments to snapshot and restore, so an auto-save plugin is short: save every tagged actor on a timer, in onScenePreDeactivate, and on pagehide, then put them back in onScenePostActivate. The game only registers its classes and decides what carries the tag.

typescript
class AutoSavePlugin extends ex.Plugin {
override onScenePostInitialize(scene: ex.Scene) {
const timer = new ex.Timer({ interval: 2000, repeats: true, action: () => this.save(scene) });
scene.add(timer);
timer.start();
}
override onScenePreDeactivate(scene: ex.Scene) {
this.save(scene);
}
override onScenePostActivate(scene: ex.Scene) {
this.restore(scene);
}
save(scene: ex.Scene) {
const actors = scene.actors.filter((actor) => actor.hasTag('saveable'));
localStorage.setItem(this.key(scene), JSON.stringify(actors.map((actor) => ex.Serializer.serializeActor(actor))));
}
restore(scene: ex.Scene) {
const json = localStorage.getItem(this.key(scene));
if (json) {
scene.actors.filter((actor) => actor.hasTag('saveable')).forEach((actor) => scene.world.remove(actor, false));
for (const data of JSON.parse(json)) {
scene.add(ex.Serializer.deserializeActor(data));
}
}
}
}
typescript
class AutoSavePlugin extends ex.Plugin {
override onScenePostInitialize(scene: ex.Scene) {
const timer = new ex.Timer({ interval: 2000, repeats: true, action: () => this.save(scene) });
scene.add(timer);
timer.start();
}
override onScenePreDeactivate(scene: ex.Scene) {
this.save(scene);
}
override onScenePostActivate(scene: ex.Scene) {
this.restore(scene);
}
save(scene: ex.Scene) {
const actors = scene.actors.filter((actor) => actor.hasTag('saveable'));
localStorage.setItem(this.key(scene), JSON.stringify(actors.map((actor) => ex.Serializer.serializeActor(actor))));
}
restore(scene: ex.Scene) {
const json = localStorage.getItem(this.key(scene));
if (json) {
scene.actors.filter((actor) => actor.hasTag('saveable')).forEach((actor) => scene.world.remove(actor, false));
for (const data of JSON.parse(json)) {
scene.add(ex.Serializer.deserializeActor(data));
}
}
}
}

Collect some gems, take the chest down to the mine, then hit reload. The hero, the score, and which gems are gone all come back, per scene:

Custom types get a registerCustomSerializer(name, serialize, deserialize) hook, and the output is validated with validateEntityData before it is trusted, so a corrupted save fails loudly instead of half-loading.

Custom Renderer​

A renderer owns its vertex layout, so it is not limited to textured quads. This water renderer runs a row of springs (each column pulls back toward rest and hands its motion to its neighbours), then uploads two triangles per column with the depth and the surface speed as vertex attributes. The fragment shader turns those into the color gradient, the drifting caustics, and the foam on fast moving crests. Every body of water in the frame lands in the same vertex buffer and one drawArrays call. The plugin side is the same three pieces as the SDF text example: a RendererPlugin, a Graphic that calls context.draw('water', ...), and a Plugin that registers it.

typescript
class WaterRenderer implements ex.RendererPlugin {
readonly type = 'water';
initialize(gl: WebGL2RenderingContext, context: ex.ExcaliburGraphicsContextWebGL) {
this._shader = new ex.Shader({ name: 'water', graphicsContext: context, vertexSource, fragmentSource });
this._shader.compile();
this._layout = new ex.VertexLayout({
gl,
shader: this._shader,
vertexBuffer: new ex.VertexBuffer({ gl, size: 5 * 6 * MAX_COLUMNS, type: 'dynamic' }),
attributes: [['a_position', 2], ['a_depth', 1], ['a_foam', 1], ['a_phase', 1]]
});
}
draw(sim: WaterSim, pos: ex.Vector, width: number, height: number) {
const transform = this._context.getTransform();
// two triangles per spring column, positions run through the current transform
}
flush() {
this._shader.use();
this._layout.use(true, this._vertexCount * 5);
this._shader.setUniformMatrix('u_matrix', this._context.ortho);
this._gl.drawArrays(this._gl.TRIANGLES, 0, this._vertexCount);
this._vertexCount = 0;
}
}
typescript
class WaterRenderer implements ex.RendererPlugin {
readonly type = 'water';
initialize(gl: WebGL2RenderingContext, context: ex.ExcaliburGraphicsContextWebGL) {
this._shader = new ex.Shader({ name: 'water', graphicsContext: context, vertexSource, fragmentSource });
this._shader.compile();
this._layout = new ex.VertexLayout({
gl,
shader: this._shader,
vertexBuffer: new ex.VertexBuffer({ gl, size: 5 * 6 * MAX_COLUMNS, type: 'dynamic' }),
attributes: [['a_position', 2], ['a_depth', 1], ['a_foam', 1], ['a_phase', 1]]
});
}
draw(sim: WaterSim, pos: ex.Vector, width: number, height: number) {
const transform = this._context.getTransform();
// two triangles per spring column, positions run through the current transform
}
flush() {
this._shader.use();
this._layout.use(true, this._vertexCount * 5);
this._shader.setUniformMatrix('u_matrix', this._context.ortho);
this._gl.drawArrays(this._gl.TRIANGLES, 0, this._vertexCount);
this._vertexCount = 0;
}
}

Click above the water to drop gems in, drag along the surface to stir it:

Sound Manager API​

SoundManager was rebuilt as an actual mixer: Sound.output → channel → master → speakers. Volume and mute are gain nodes at each level instead of numbers multiplied into every play() call, which means muting no longer pauses, a muted sound keeps advancing and is mid-clip when you unmute, and channel.setVolume() finally does something.

Every bus exposes its Web Audio nodes, so filters, compressors, reverb, analysers, and spatial panning are a few lines each:

typescript
const mixer = new ex.SoundManager({
channels: [
{ name: 'fx', maxConcurrentTracks: 4, voiceStealing: true },
{
name: 'music',
volume: 0.5,
// channel bus effects: everything on this channel runs through the filter
onConnect: ({ input, output }) => input.connect(lowpass).connect(output)
}
],
sounds: [
{ name: 'dig', sound: dig, channel: 'fx', volume: 0.6 },
{ name: 'music', sound: title, channel: 'music' }
],
// master bus effects
onConnect: ({ audioContext, input, output }) => {
input.connect(audioContext.createDynamicsCompressor()).connect(output);
}
});
// per-play overrides never change the Sound's configuration
mixer.play('dig', {
pitch: -300, // cents
onPlay: ({ audioContext, source, destination }) => {
const panner = audioContext.createStereoPanner();
panner.pan.value = -0.8;
source.connect(panner).connect(destination);
}
});
// effect nodes stay live, automate them and the mix changes in place
lowpass.frequency.setTargetAtTime(400, audioContext.currentTime, 0.1);
// channels expose their nodes too, if you would rather rewire by hand
const music = mixer.getChannel('music');
music.input.disconnect();
music.input.connect(reverb).connect(music.output);
typescript
const mixer = new ex.SoundManager({
channels: [
{ name: 'fx', maxConcurrentTracks: 4, voiceStealing: true },
{
name: 'music',
volume: 0.5,
// channel bus effects: everything on this channel runs through the filter
onConnect: ({ input, output }) => input.connect(lowpass).connect(output)
}
],
sounds: [
{ name: 'dig', sound: dig, channel: 'fx', volume: 0.6 },
{ name: 'music', sound: title, channel: 'music' }
],
// master bus effects
onConnect: ({ audioContext, input, output }) => {
input.connect(audioContext.createDynamicsCompressor()).connect(output);
}
});
// per-play overrides never change the Sound's configuration
mixer.play('dig', {
pitch: -300, // cents
onPlay: ({ audioContext, source, destination }) => {
const panner = audioContext.createStereoPanner();
panner.pan.value = -0.8;
source.connect(panner).connect(destination);
}
});
// effect nodes stay live, automate them and the mix changes in place
lowpass.frequency.setTargetAtTime(400, audioContext.currentTime, 0.1);
// channels expose their nodes too, if you would rather rewire by hand
const music = mixer.getChannel('music');
music.input.disconnect();
music.input.connect(reverb).connect(music.output);

Also new: Sound.pitch, Sound.start() which returns the SoundTrack synchronously so you can stop or seek one playback, maxConcurrentTracks caps with voiceStealing, Sound.fromBlob(), and the SoundManager is Loadable so one loader.addResource(mixer) loads everything it manages.

Turn your volume up and dig around (digs pan to where you click and no two have the same pitch, some tiles hide gems), then try the music buttons. The mixer is one Loadable, so the Loader fetches every sound it manages:

Music by Tim Peterson

Spatial Audio​

Because onPlay hands you the raw Web Audio graph, positional sound is the same hook with a PannerNode instead of a stereo panner. The context also includes the SoundTrack being started, which is the natural key for remembering per-playback effect nodes, so a moving emitter can keep updating its panner every frame. Sound.start() (and SoundManager.start()) return that track synchronously, and the AudioContext listener follows the player:

typescript
const panners = new WeakMap<ex.SoundTrack, PannerNode>();
const spatialize =
(pos: ex.Vector): ex.AudioGraphBuilder =>
({ audioContext, source, destination, track }) => {
const panner = new PannerNode(audioContext, {
panningModel: 'HRTF',
distanceModel: 'inverse',
refDistance: 60, // pixels
positionX: pos.x,
positionZ: pos.y
});
source.connect(panner).connect(destination);
panners.set(track, panner);
};
// a looping emitter, moved every frame
const scurry = mixer.start('scurry', { onPlay: spatialize(beetle.pos) });
beetle.onPostUpdate = () => {
const panner = panners.get(scurry!)!;
panner.positionX.setTargetAtTime(beetle.pos.x, audioContext.currentTime, 0.05);
panner.positionZ.setTargetAtTime(beetle.pos.y, audioContext.currentTime, 0.05);
};
// one-shots are positioned the same way
mixer.play('bite', { onPlay: spatialize(beetle.pos) });
// the player is the listener
audioContext.listener.positionX.setTargetAtTime(player.pos.x, audioContext.currentTime, 0.05);
audioContext.listener.positionZ.setTargetAtTime(player.pos.y, audioContext.currentTime, 0.05);
typescript
const panners = new WeakMap<ex.SoundTrack, PannerNode>();
const spatialize =
(pos: ex.Vector): ex.AudioGraphBuilder =>
({ audioContext, source, destination, track }) => {
const panner = new PannerNode(audioContext, {
panningModel: 'HRTF',
distanceModel: 'inverse',
refDistance: 60, // pixels
positionX: pos.x,
positionZ: pos.y
});
source.connect(panner).connect(destination);
panners.set(track, panner);
};
// a looping emitter, moved every frame
const scurry = mixer.start('scurry', { onPlay: spatialize(beetle.pos) });
beetle.onPostUpdate = () => {
const panner = panners.get(scurry!)!;
panner.positionX.setTargetAtTime(beetle.pos.x, audioContext.currentTime, 0.05);
panner.positionZ.setTargetAtTime(beetle.pos.y, audioContext.currentTime, 0.05);
};
// one-shots are positioned the same way
mixer.play('bite', { onPlay: spatialize(beetle.pos) });
// the player is the listener
audioContext.listener.positionX.setTargetAtTime(player.pos.x, audioContext.currentTime, 0.05);
audioContext.listener.positionZ.setTargetAtTime(player.pos.y, audioContext.currentTime, 0.05);

Put on headphones, then click or use WASD to walk. The beetle scurries past from one ear to the other and bites when it catches you, and the music box in the corner swells as you get close:

Music by Tim Peterson

Layered Audio​

The mixer also makes layered, adaptive music simple. Give every stem the same scheduledStartTime on the audio clock and they start sample-aligned and stay in phase, then crossfade them with setVolume as the game state changes. This is exactly how Gem Jam, our Ludum Dare 58 entry, brings in the drums and the topper as you dig deeper, and the sample below uses its stems. Move the mouse down to dig:

typescript
const start = ex.AudioContextFactory.currentTime() + 200; // milliseconds on the audio clock
mixer.play('surface', { scheduledStartTime: start });
mixer.play('drums', { scheduledStartTime: start });
mixer.play('topper', { scheduledStartTime: start });
// later, per frame: the sound's mix gain, Sound.volume is untouched
mixer.setVolume('drums', depth);
typescript
const start = ex.AudioContextFactory.currentTime() + 200; // milliseconds on the audio clock
mixer.play('surface', { scheduledStartTime: start });
mixer.play('drums', { scheduledStartTime: start });
mixer.play('topper', { scheduledStartTime: start });
// later, per frame: the sound's mix gain, Sound.volume is untouched
mixer.setVolume('drums', depth);

Music by Tim Peterson

Excalibur CLI ex​

create-excalibur grew up. It still scaffolds a game in your preferred frontend stack, and now it also:

sh
npx create-excalibur@latest # scaffold a game
npx create-excalibur@latest upgrade # migrate to a newer Excalibur, codemod-style
npx create-excalibur@latest doctor # type-aware diagnostics for common mistakes
npx create-excalibur@latest generate # generate an actor, scene, material, spritesheet, ...
npx create-excalibur@latest docs # search the docs from your terminal, offline too
npx create-excalibur@latest mcp # MCP server so AI agents use the real docs and codemods
sh
npx create-excalibur@latest # scaffold a game
npx create-excalibur@latest upgrade # migrate to a newer Excalibur, codemod-style
npx create-excalibur@latest doctor # type-aware diagnostics for common mistakes
npx create-excalibur@latest generate # generate an actor, scene, material, spritesheet, ...
npx create-excalibur@latest docs # search the docs from your terminal, offline too
npx create-excalibur@latest mcp # MCP server so AI agents use the real docs and codemods

If you install it globally you have access to the ex command for short ((yes, I did overload the oldschool text editor command...)[https://en.wikipedia.org/wiki/Ex_(text_editor)])

sh
ex upgrade # migrate to a newer Excalibur, codemod-style
ex doctor # type-aware diagnostics for common mistakes
ex generate # generate an actor, scene, material, spritesheet, ...
ex docs # search the docs from your terminal, offline too
ex mcp # MCP server so AI agents use the real docs and codemods
sh
ex upgrade # migrate to a newer Excalibur, codemod-style
ex doctor # type-aware diagnostics for common mistakes
ex generate # generate an actor, scene, material, spritesheet, ...
ex docs # search the docs from your terminal, offline too
ex mcp # MCP server so AI agents use the real docs and codemods

ICYMI​

A* pathfinding​

The pathfinding plugin gives you A*, Dijkstra, breadth first and depth first search over a graph you build by hand or straight from the cells of a TileMap. Click a tile and the knight finds its way around the stone and the spiders, click a stone tile to clear it or a spider to squash it and watch the route change. The search reports how long it took, and it is fast enough to run on every click. The art and sound effects are from the sample tactics game.

Art authored by discord member HintOfLime!

Nine-slice​

ex.NineSlice takes a small source image and grows it to any size while keeping the corners and edges crisp. Each axis can stretch, tile, or tile-fit the middle region independently, which covers dialog boxes, buttons, health bars and textured platforms with one tiny asset. The source here is the 20x15 brick tile from the sample platformer, so the three modes are easy to tell apart: stretch smears the bricks, tile repeats them, and tile-fit squeezes in whole bricks.

Pixel art mode​

Pixel art is deceptively hard to render well. Plain nearest-neighbor sampling shimmers when sprites move at fractional speeds and grows fat and thin pixels when they rotate, while plain bilinear filtering turns everything blurry. new ex.Engine({ pixelArt: true }) enables a special pixel art sampler in the shader that snaps to texel edges but antialiases the boundary between them, keeps the texture filtering and canvas settings that make it work.

Read more here, we use the Inigo Quilez version of the pixel art filter

GPU particles​

ex.GpuParticleEmitter has the same configuration as the CPU ParticleEmitter but simulates and draws the particles on the GPU. Tens of thousands of particles cost roughly the same as a few hundred, so rain, sparks, dust and spell effects stop being a performance burdne. Good looking fire is really four cheap emitters stacked up.

And more​

  • Coroutines. ex.coroutine(engine, function* () { ... }) runs a generator on the engine clock. A bare yield waits a frame and hands you the elapsed time, yield 500 waits 500 milliseconds, and yielding a promise awaits it. They are a wonderful way to write cutscenes, boss patterns and multi-step animations as straight-line code.
  • Scene transitions and per-scene loaders. Scenes can declare their own resources and in/out transitions, or you can pass them at the call site: engine.goToScene('level2', { destinationIn: new ex.Slide({ duration: 500, slideDirection: 'left' }) }). FadeInOut, CrossFade and Slide ship in the box, and Transition is easy to extend.
  • Bezier curves. ex.BezierCurve for sampling cubic curves, and actor.actions.curveTo({ controlPoints: [c1, c2, end], duration }) and curveBy to move along them.
  • Input mapping. engine.inputMapper.on(({ keyboard, gamepads }) => ..., command) maps any combination of keyboard, gamepad and pointer state onto game commands, which is also the foundation for remappable controls and accessibility options.
  • Materials. Per-actor fragment shaders with new ex.Material({ fragmentSource }), with extra images and uniforms. v1's glsl tag and shader pipelines build on this.
  • Tiling sprites. ex.TiledSprite and ex.TiledAnimation repeat a sprite across a region at the GPU's expense instead of yours, and SpriteSheet.getTiledSprite pulls one out of a sheet.
  • Fixed update. new ex.Engine({ fixedUpdateFps: 60 }) decouples simulation from the display refresh rate so physics behaves the same on a 60 Hz laptop and a 240 Hz monitor. The pyramid above relies on it.
  • Debug drawing anywhere. ex.Debug.drawLine, drawCircle, drawText and friends work from any code path, no graphics context required, and engine.toggleDebug() flips the whole debug overlay.
  • Typed events. ex.EventEmitter<TEventMap> gives every events.on(...) call autocompletion and checked payloads, and ex.StateMachine is a tiny typed finite state machine for AI and UI flows.

Migrating to v1​

We are hoping to make migrating v0.x games easy, with the npx create-excalibur@latest upgrade command

Future Plans​

  • WebGPU + HTML-in-Canvas
  • Excalibur Editor (think Unity/Godot but for Excalibur)
  • Console porting
  • Inaugural Excalibur Game Jam! (SOOOON)
  • Commercial games from us at Caliburn Games

Thank you​

Excalibur started in January 2013 as a side project between friends, and it got to v1 because people kept showing up. Every one of these folks landed a commit in the core engine over the last thirteen years:

eonarheimkamranayubjedeenjyoung4242mattjenningschrisk-7777alanag13Autsider666hogartcatrielmullerdjcsdyLoserAntbearisokissamuhajirdevmosesintechjasonlh-harrisCrizzoooandrewmbyrdSebringmrkmgPurityLakeT00mmmathieuherrougepiedkeasy9edualbbutlersreposXstoudiDaVincethplatJumpLinkNimgoblemiqhSanjoSolutionshtalatguahanwebCarghaezDavidLi119nidbleAdamduehansenpremsreelathasugeendranrobbertstevensrubenmarcusrledfordsweenistsaahilkelectro75SamuelAsherRivelloscottwestoverpikoohSirPedrpathursNysosisjpninanjohnnprettoMrBartusekMohammadYusifmseynesnowyukittysellmarkjellewijmajamesmcgeorgerjpgtikudrickiyhelloausrineegodjuksdenetoraruru-weedZuzuZainvritant24tonivj5kala2TheEyeOfBrowssylfelSurajGoelSulthanNKGuzzlermalitherlDavDagdavidagredanoKakkoiDevcybrowncosmicarrowcdelstadchrispanagchrisalleyclofreshignoreintuitionblakearobertsnascobAphonicChaosagrgic16ThrakaBaconSoapradist2sSirKitboardEpocDotFrreissbakermcavazottijurcaMaanasAroralucasromanJustLeifkfalicovJoshua-BeattyJerry457JeTmAn1981Clemix37gsilisgavriguygargraveeugenemeddylanavery720Drknessfall

Special thanks to the core team past and present: Kamran Ayub, Jae Edeen, Justin Young, Matt Jennings, Cjadwick, Chris K, Alan Grgic for the kind of sustained contributions that move an engine forward.

Additional thanks to HintOfLime for their fantastic art skills ♥️

Cal swinging a sword

WE HAVE EXCALIBUR!!!!!