This can be done with Promise.all and image.decode(). Once Promise.all resolves, we can call the initialize function and continue on with the rest of the logic. This avoids race conditions where the main game loop's requestAnimationFrame is called from bird.onload, but it's possible that pipe entities and so forth haven't loaded yet.
Here's a minimal, complete example:
const initialize = images => { // images are loaded here and we can go about our business const canvas = document.createElement("canvas"); document.body.appendChild(canvas); canvas.width = 400; canvas.height = 200; const ctx = canvas.getContext("2d"); Object.values(images).forEach((e, i) => ctx.drawImage(e, i * 100, 0) ); }; const imageUrls = [ "https://picsum.photos/90/100", "https://picsum.photos/90/130", "https://picsum.photos/90/160", "https://picsum.photos/90/190", ]; Promise.all(imageUrls.map(async e => { const img = new Image(); img.src = e; await img.decode(); return img; })).then(initialize);
Notice that I used an array in the above example to store the images. The problem this solves is that the
var foo = ... var bar = ... var baz = ... var qux = ... foo.src = ... bar.src = ... baz.src = ... qux.src = ... foo.onload = ... bar.onload = ... baz.onload = ... qux.onload = ...
pattern is extremely difficult to manage and scale. If you decide to add another thing into the game, then the code needs to be re-written to account for it and game logic becomes very wet. Bugs become difficult to spot and eliminate. Also, if we want a specific image, we'd prefer to access it like images.bird rather than images[1], preserving the semantics of the individual variables, but giving us the power to loop through the object and call each entity's render function, for example.
All of this motivates an object to aggregate game entities. Some information we'd like to have per entity might include, for example, the entity's current position, dead/alive status, functions for moving and rendering it, etc.
It's also a nice idea to have some kind of separate raw data object that contains all of the initial game state (this would typically be an external JSON file).
Clearly, this can turn into a significant refactor, but it's a necessary step when the game grows beyond small (and we can incrementally adopt these design ideas). It's generally a good idea to bite the bullet up front.
Here's a proof-of-concept illustrating some of the the musings above. Hopefully this offers some ideas for how you might manage game state and logic.
const entityData = [ { name: "foo", path: "https://picsum.photos/80/80", x: 0, y: 0 }, { name: "baz", path: "https://picsum.photos/80/150", x: 0, y: 90 }, { name: "quux", path: "https://picsum.photos/100/130", x: 90, y: 110 }, { name: "corge", path: "https://picsum.photos/200/240", x: 200, y: 0 }, { name: "bar", path: "https://picsum.photos/100/100", x: 90, y: 0 } /* you can add more properties and functions (movement, etc) to each entity ... try adding more entities ... */ ]; const entities = entityData.reduce((a, e) => { a[e.name] = {...e, image: new Image(), path: e.path}; return a; }, {}); const initialize = () => { const canvas = document.createElement("canvas"); document.body.appendChild(canvas); canvas.width = innerWidth; canvas.height = innerHeight; const ctx = canvas.getContext("2d"); for (const key of Object.keys(entities)) { entities[key].alpha = Math.random(); } (function render () { ctx.clearRect(0, 0, canvas.width, canvas.height); Object.values(entities).forEach(e => { ctx.globalAlpha = Math.abs(Math.sin(e.alpha += 0.005)); ctx.drawImage(e.image, e.x, e.y); ctx.globalAlpha = 1; }); requestAnimationFrame(render); })(); }; Promise.all(Object.values(entities).map(e => { e.image.src = e.path; return e.image.decode(); })) .then(initialize) .catch(err => console.error(err));