Excalibur v1.0.0 Released!
It's finally happening! Excalibur.js, has been a labor of love for thirteen years! And we are finally here at v1!
shnpm install [email protected]
shnpm install [email protected]
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
- SteamSpin
- PixelWar
- World Gen and Baby Bots
- Some Deity Simulator and SeaDV
- Asteroids
- Ten of You
- Tanium Game Jam 2025
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!
typescriptconst 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 flickerconst 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 shadowcrate.addComponent(new ex.LightOccluderComponent({ shape: { kind: 'box', width: 48, height: 48 } }));
typescriptconst 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 flickerconst 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 shadowcrate.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.
typescriptconst graphicsContext = game.graphicsContext;// Per graphic: `padding` gives the halo room outside the sprite's quadconst glow = new ex.GlowEffect({ graphicsContext, color: ex.Color.Cyan, strength: 3 });actor.graphics.material = new ex.Material({ graphicsContext, passes: glow, padding: 24 });// Fullscreengame.graphicsContext.addPostProcessor(new ex.ShaderPipelinePostProcessor({passes: new ex.BloomEffect({ graphicsContext, threshold: 0.5, intensity: 1.2 })}));// Settings are liveglow.intensity = 0.5;
typescriptconst graphicsContext = game.graphicsContext;// Per graphic: `padding` gives the halo room outside the sprite's quadconst glow = new ex.GlowEffect({ graphicsContext, color: ex.Color.Cyan, strength: 3 });actor.graphics.material = new ex.Material({ graphicsContext, passes: glow, padding: 24 });// Fullscreengame.graphicsContext.addPostProcessor(new ex.ShaderPipelinePostProcessor({passes: new ex.BloomEffect({ graphicsContext, threshold: 0.5, intensity: 1.2 })}));// Settings are liveglow.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:
typescriptconst 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 assignmaterial.uniforms.u_dissolve = 0.5;
typescriptconst 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 assignmaterial.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).
typescriptex.Serializer.init(); // registers the built-in componentsex.Serializer.registerGraphics({ gem: gemGraphic, hero: heroGraphic });ex.Serializer.registerComponent(LootComponent);ex.Serializer.registerCustomActors([Gem, Hero]);// save: everything tagged 'saveable' becomes JSONconst 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 restoredfor (const data of JSON.parse(localStorage.getItem('save')!)) {scene.add(ex.Serializer.deserializeActor(data)!);}
typescriptex.Serializer.init(); // registers the built-in componentsex.Serializer.registerGraphics({ gem: gemGraphic, hero: heroGraphic });ex.Serializer.registerComponent(LootComponent);ex.Serializer.registerCustomActors([Gem, Hero]);// save: everything tagged 'saveable' becomes JSONconst 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 restoredfor (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!
typescriptconst engine = new ex.Engine({plugins: [new MyCoolPlugin()]});
typescriptconst 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.
typescriptclass 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));}}}}
typescriptclass 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.
typescriptclass 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;}}
typescriptclass 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:
typescriptconst 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 filteronConnect: ({ 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 effectsonConnect: ({ audioContext, input, output }) => {input.connect(audioContext.createDynamicsCompressor()).connect(output);}});// per-play overrides never change the Sound's configurationmixer.play('dig', {pitch: -300, // centsonPlay: ({ 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 placelowpass.frequency.setTargetAtTime(400, audioContext.currentTime, 0.1);// channels expose their nodes too, if you would rather rewire by handconst music = mixer.getChannel('music');music.input.disconnect();music.input.connect(reverb).connect(music.output);
typescriptconst 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 filteronConnect: ({ 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 effectsonConnect: ({ audioContext, input, output }) => {input.connect(audioContext.createDynamicsCompressor()).connect(output);}});// per-play overrides never change the Sound's configurationmixer.play('dig', {pitch: -300, // centsonPlay: ({ 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 placelowpass.frequency.setTargetAtTime(400, audioContext.currentTime, 0.1);// channels expose their nodes too, if you would rather rewire by handconst 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:
typescriptconst 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, // pixelspositionX: pos.x,positionZ: pos.y});source.connect(panner).connect(destination);panners.set(track, panner);};// a looping emitter, moved every frameconst 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 waymixer.play('bite', { onPlay: spatialize(beetle.pos) });// the player is the listeneraudioContext.listener.positionX.setTargetAtTime(player.pos.x, audioContext.currentTime, 0.05);audioContext.listener.positionZ.setTargetAtTime(player.pos.y, audioContext.currentTime, 0.05);
typescriptconst 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, // pixelspositionX: pos.x,positionZ: pos.y});source.connect(panner).connect(destination);panners.set(track, panner);};// a looping emitter, moved every frameconst 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 waymixer.play('bite', { onPlay: spatialize(beetle.pos) });// the player is the listeneraudioContext.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:
typescriptconst start = ex.AudioContextFactory.currentTime() + 200; // milliseconds on the audio clockmixer.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 untouchedmixer.setVolume('drums', depth);
typescriptconst start = ex.AudioContextFactory.currentTime() + 200; // milliseconds on the audio clockmixer.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 untouchedmixer.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:
shnpx create-excalibur@latest # scaffold a gamenpx create-excalibur@latest upgrade # migrate to a newer Excalibur, codemod-stylenpx create-excalibur@latest doctor # type-aware diagnostics for common mistakesnpx create-excalibur@latest generate # generate an actor, scene, material, spritesheet, ...npx create-excalibur@latest docs # search the docs from your terminal, offline toonpx create-excalibur@latest mcp # MCP server so AI agents use the real docs and codemods
shnpx create-excalibur@latest # scaffold a gamenpx create-excalibur@latest upgrade # migrate to a newer Excalibur, codemod-stylenpx create-excalibur@latest doctor # type-aware diagnostics for common mistakesnpx create-excalibur@latest generate # generate an actor, scene, material, spritesheet, ...npx create-excalibur@latest docs # search the docs from your terminal, offline toonpx 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)])
shex upgrade # migrate to a newer Excalibur, codemod-styleex doctor # type-aware diagnostics for common mistakesex generate # generate an actor, scene, material, spritesheet, ...ex docs # search the docs from your terminal, offline tooex mcp # MCP server so AI agents use the real docs and codemods
shex upgrade # migrate to a newer Excalibur, codemod-styleex doctor # type-aware diagnostics for common mistakesex generate # generate an actor, scene, material, spritesheet, ...ex docs # search the docs from your terminal, offline tooex 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 bareyieldwaits a frame and hands you the elapsed time,yield 500waits 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/outtransitions, or you can pass them at the call site:engine.goToScene('level2', { destinationIn: new ex.Slide({ duration: 500, slideDirection: 'left' }) }).FadeInOut,CrossFadeandSlideship in the box, andTransitionis easy to extend. - Bezier curves.
ex.BezierCurvefor sampling cubic curves, andactor.actions.curveTo({ controlPoints: [c1, c2, end], duration })andcurveByto 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'sglsltag and shader pipelines build on this. - Tiling sprites.
ex.TiledSpriteandex.TiledAnimationrepeat a sprite across a region at the GPU's expense instead of yours, andSpriteSheet.getTiledSpritepulls 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,drawTextand friends work from any code path, no graphics context required, andengine.toggleDebug()flips the whole debug overlay. - Typed events.
ex.EventEmitter<TEventMap>gives everyevents.on(...)call autocompletion and checked payloads, andex.StateMachineis 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:
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 ♥️

WE HAVE EXCALIBUR!!!!!
