In this tutorial, we will go through the process of creating a remixable Falling Objects game with an existing p5.js Game Template as our starting point.
This tutorial assumes you’ve already gone through the Getting Started Tutorial and are familiar with the basics of Koji.
Let’s get started by cloning the Game Template. You can do that by clicking on the Koji logo on the upper right corner, then Advanced, then find the Fork button.
You are free to choose between using the Koji web editor or cloning the newly created project to your local environment.
If you get stuck at any point, feel free to ask for help in our Discord Server, or e-mail me at marko@withkoji.com
You can also find an overview of the whole game template inside the README contained inside the project after you clone it.
Falling Objects is a simple game where the player tries to collect as many falling objects as they can without them falling off the screen.
Player loses a life if an object falls down, and the game is over when there are no lives left.
Player moves the character horizontally on the bottom by touching or clicking anywhere on the screen.
First, let’s define our game assets.
Inside of koji.json, you will find the remixData object.
Let’s add some entries to it.
We will need images for our Player and Collectible objects:
"imgPlayer": "https://images.koji-cdn.com/814f445d-4e8d-40cb-83fe-04d120cc1889/userData/x0kfr-player.png"
"imgCollectible": "https://images.koji-cdn.com/5f618aa2-e515-49a1-adb2-42dfb88a6f59/userData/1d6jr-donut.png"
As well as sounds for when the player collects or misses anything:
"sndCollect": "https://objects.koji-cdn.com/814f445d-4e8d-40cb-83fe-04d120cc1889/userData/ydx3v-collect01onlineaudioconverter.com.mp3.mp3",
"sndLoseLife": "https://objects.koji-cdn.com/814f445d-4e8d-40cb-83fe-04d120cc1889/userData/vqnq8-loseLife.mp3.mp3",
|
Note
|
It’s preferrable that your sound assets are prefixed with snd. That way, they’ll be loaded automatically during preload.
|
You will also notice that there are already some other entries in our remixData by default.
Let’s also modify the title and backgroundImage entries to give our game a more distinct look:
"title": "Catch The Donuts"
"backgroundImage": "https://images.koji-cdn.com/377f7596-8edf-44b1-994d-f9926b3aa641/userData/682dt-G68b11.png"
Your remixData object should now look something like this:
"remixData": {
"music": "https://objects.koji-cdn.com/45cdb5ca-8318-4318-8271-d0e422e790e0/hjdo0-music.mp3",
"musicVolume": 0.25,
"title": "Catch The Donuts",
"backgroundImage": "https://images.koji-cdn.com/377f7596-8edf-44b1-994d-f9926b3aa641/userData/682dt-G68b11.png",
"imgParticles": [
"https://images.koji-cdn.com/d4d2aab7-1847-481d-8aa6-6866d81d5e0f/6ik7b-star1.png",
"https://images.koji-cdn.com/d4d2aab7-1847-481d-8aa6-6866d81d5e0f/xvpr0-star3.png",
"https://images.koji-cdn.com/d4d2aab7-1847-481d-8aa6-6866d81d5e0f/0pfca-start2.png",
"https://images.koji-cdn.com/d4d2aab7-1847-481d-8aa6-6866d81d5e0f/mwf5o-star5.png"
],
"particle": "https://images.koji-cdn.com/d4d2aab7-1847-481d-8aa6-6866d81d5e0f/zfnue-particle2.png",
"imgLife": "https://images.koji-cdn.com/c166301e-e9be-4a9c-8d08-aaffe23d82bb/userData/3z6gw-life.png",
"imgPlayer": "https://images.koji-cdn.com/814f445d-4e8d-40cb-83fe-04d120cc1889/userData/x0kfr-player.png",
"imgCollectible": "https://images.koji-cdn.com/5f618aa2-e515-49a1-adb2-42dfb88a6f59/userData/1d6jr-donut.png",
"sndCollect": "https://objects.koji-cdn.com/814f445d-4e8d-40cb-83fe-04d120cc1889/userData/ydx3v-collect01onlineaudioconverter.com.mp3.mp3",
"sndLoseLife": "https://objects.koji-cdn.com/814f445d-4e8d-40cb-83fe-04d120cc1889/userData/vqnq8-loseLife.mp3.mp3"
},|
Note
|
Don’t forget to save your changes and reload the live preview after editing koji.json
|
If you have a Live Preview running (right side of your screen in the Web Editor), you’ll notice that the background image has already changed, as well as the player image in the main menu!
Now that we’ve added our assets to remixData, we need to load them inside our game so we can use them later.
We will do all of our loading in frontend/src/Components/Game/preload.js
We do not have to do anything to load our sounds, since they will be loaded automatically as their keys are prefixed with snd.
To load images, we can use addImage():
const preload = () => {
const remixValues = Koji.remix.get();
...
addImage('player', remixValues.imgPlayer, game.playerSize);
addImage('collectible', remixValues.imgCollectible, 100);
...
}Note that we’ve used game.playerSize as player size. We need to define that as well. We will define all of our game settings in initializeValues():
import isMobile from '../../Utils/isMobile'
...
const initializeValues = () => {
...
game.playerSize = isMobile() ? 90 : 110;
}Since there’s a considerable difference in mobile and desktop display sizes, we can change our playerSize according to the device using the isMobile() function provided by the template.
We can also change game instructions here by modifying game.instructions property.
Change it to something like:
game.instructions = "Touch to move around!";
This template uses an Entity system, so we can extend the base Entity class from our template to define the game objects.
Let’s go to Player.js inside of our frontend/src/Components/Game/Entities folder.
|
Note
|
This template already has an example Player class defined, so no need to create a new file, we can use that!
|
Let’s modify the constructor to give it an image that we’ve already loaded, and give it a size:
import { game } from '..'
import Entity from './Entity'
export default class Player extends Entity {
constructor(x, y, options) {
super(x, y, options);
this.img = game.images.player;
this.size = game.playerSize;
}
update(){
}
}|
Note
|
update() function should also be cleared in case there’s any example code in it. We will modify it later.
|
That was all we needed to do to have our Player object ready to be drawn to the screen! Now we just need to instantiate it.
Head over to frontend/src/Components/Game/setup.js where you’ll find the init() function. Clear everything inside it and change it to:
import { game } from "."
import Player from './Entities/Player'
...
const init = () => {
const x = game.width / 2;
const y = game.height - 160;
const player = new Player(x, y);
game.addEntity(player);
game.player = player;
}Here we defined our x and y coordinates to spawn our Player on (center bottom of the screen), added the player instance to our list of entities, and also assigned the player object to our game instance so we can easily access it later.
If we take a look at the game preview now and click on the Play button, we should see our Player image on the bottom of the screen.
Awesome!
But since this is a game we’re making, we want our Player to move when we tell it to, not just sit around!
To do that, let’s head back to our frontend/src/Components/Game/Entities/Player.js file.
We’ll define a new function to handle our player movement, call it handleControls(), and put it inside update() which runs every frame:
import { game } from '..';
import { Smooth } from '../Utils/EasingFunctions'
...
update(){
this.handleControls();
}
handleControls(){
if(!game.isTouching) return; // Do nothing if we're not touching or clicking
this.pos.x = Smooth(this.pos.x, game.mouseX, 13);
}We’re using the Smooth function from the template’s Game/Utils/EasingFunctions.js file, so the player can move smoothly to its goal, which is our game.mouseX touch coordinate. The number parameter at the end determines how quickly that happens. The higher it is, the slower the player will move. Feel free to experiment with different numbers if you wish!
If we try the game now, the player should move left and right wherever we touch or click on the screen.
This works, but let’s make it prettier!
What if the player tilted slightly in the direction it’s moving? We also don’t want to move outside of the screen for any reason.
Let’s add those features by expanding our Player class some more:
import { game } from '..';
import { Smooth } from '../Utils/EasingFunctions'
...
export default class Player extends Entity {
constructor(x, y, options) {
...
this.goalRotation = 0;
}
}
update(){
this.handleControls();
this.rotation = Smooth(this.rotation, this.goalRotation, 8);
}
handleControls(){
if(game.isTouching){
this.pos.x = Smooth(this.pos.x, game.mouseX, 13);
this.keepInsideScreen();
const isTouchingFarEnough = Math.abs(this.pos.x - game.mouseX) > this.size / 2;
if (isTouchingFarEnough) {
const movingDirection = Math.sign(game.mouseX - this.pos.x);
this.goalRotation = movingDirection * game.radians(15);
}else{
this.goalRotation = 0;
}
}else{
this.goalRotation = 0;
}
}
keepInsideScreen() {
const limitLeft = this.size / 2;
const limitRight = game.width - this.size / 2;
this.pos.x = game.constrain(this.pos.x, limitLeft, limitRight);
}With keepInsideScreen(), we’re simply constraining the player’s x coordinate so it doesn’t overlap or go outside the screen boundaries.
Then we check if we’re touching outside of the player, in which case we’re setting goalRotation slightly to that side.
Looking good so far!
We have the player. Now we need something to collect!
Let’s make a new Collectible.js file inside the frontend/src/Components/Game/Entities folder, and give it some properties:
import { game } from '..'
import Entity from './Entity'
export default class Collectible extends Entity {
constructor(x, y, options) {
super(x, y, options);
this.img = game.images.collectible;
this.size = game.random(game.collectibleSizeMin, game.collectibleSizeMax);
this.tag = "collectible";
this.velocity.y = game.random(game.speedMin, game.speedMax);
this.rotSpeed = game.random(-0.03, 0.03);
this.isCollected = false;
}
}We have a bit more going on here than with player. Let’s break it down:
this.img = game.images.collectible - give it a previously loaded Collectible image
this.size = game.random(game.collectibleSizeMin, game.collectibleSizeMax) - set the size to a random value between game.collectibleSizeMin and game.collectibleSizeMax
this.tag = "collectible"` - set the tag, so we can find it later
this.velocity.y = game.random(game.speedMin, game.speedMax) - set the vertical velocity to a random value between game.speedMin and game.speedMax
this.rotSpeed = game.random(-0.03, 0.03) - set the rotation speed to a random value between -0.03 and 0.03
this.isCollected = false - a boolean to track whether it’s been collected or not
In order for this to work, we need to define some more values inside the game settings, so we’ll head back to frontend/src/Components/Game/preload.js, and in initializeValues() add:
...
const initializeValues = () => {
...
game.speedMin = 2;
game.speedMax = 4;
game.collectibleSizeMin = isMobile() ? 55 : 85;
game.collectibleSizeMax = isMobile() ? 75 : 105;
}Since the Entity class has its movement already handled based on the velocity property that we already modified in the Collectible constructor, that’s all we needed to do to have our Collectibles fall down indefinitely!
Now let’s try spawning some!
Collectibles need to spawn throughout the game, and not just in the beginning like the Player does. We can do that inside frontend/src/Components/Game/draw.js. Every function placed here will run with each game frame.
We’ll define a new manageSpawn() function that will handle our spawning logic, then put it inside draw():
...
import Collectible from './Entities/Collectible'
const draw = () => {
...
manageSpawn();
}
const manageSpawn = () => {
game.spawnTimer -= game.delta();
if (game.spawnTimer <= 0) {
spawnCollectible();
game.spawnTimer = game.random(game.spawnPeriodMin, game.spawnPeriodMax);
}
}
const spawnCollectible = () => {
const x = game.random(0, game.width);
const y = -game.collectibleSizeMax;
game.addEntity(new Collectible(x, y));
}Every frame, we’ll decrease game.spawnTimer by game.delta(). Once it reaches 0, we spawn a new collectible at a random point above the screen and reset the timer to a random value between game.spawnPeriodMin and game.spawnPeriodMin.
Once again, we need to define the new values in game settings:
...
const initializeValues = () => {
...
game.spawnPeriodMin = 1.5;
game.spawnPeriodMax = 2;
game.spawnTimer = 0.1;
}If you start the game now, you should see a bunch of donuts appearing every 1.5 to 2 seconds and falling to the bottom.
That’s nice and all, but how do we catch them? No point in falling donuts if you can’t eat them!
To check for collisions between Player and Collectibles, we’ll modify frontend/src/Components/Game/Entities/Player.js again.
Let’s add a new function to do that, and put it in update()!
...
import { CollisionCircle } from '../Utils/Collision'
export default class Player extends Entity {
...
update(){
...
this.checkCollisions();
}
checkCollisions() {
const collectibles = game.findByTag('collectible');
collectibles.forEach(collectible => {
if (!collectible.isCollected && CollisionCircle(this, collectible)) {
collectible.onCollect();
}
});
}
}First, we use game.findByTag() to filter our collectibles, then run through each one asking if they are colliding with the Player.
For that, we’re using CollisionCircle(), provided by the game template (from frontend/src/Components/Game/Utils/Collision), which takes two Entities as arguments.
So if we are indeed colliding, and the collectible in question hasn’t already been collected, we trigger the onCollect() handler on the target collectible which we will define next.
Back to frontend/src/Components/Game/Entities/Collectible.js:
...
export default class Collectible extends Entity {
...
onCollect() {
this.isCollected = true;
}
}This just sets our isCollected flag to true.
Now, we could just remove our Collectible object now and be done with it, but we don’t want this to be like any other tutorial. Let’s spice things up a bit!
Instead of the Collectible just disappearing upon contact, let’s have the Player attract it like a magnet and make it explode into particles! That sounds more fun!
So let’s override Collectible’s update() function, but also keep the original behavior:
import { game } from '..'
import Entity from './Entity'
import playSound from '../Utils/playSound'
import { Smooth, Ease, EasingFunctions } from '../Utils/EasingFunctions'
import { spawnParticles } from '../Effects/Particle'
import { spawnFloatingText } from '../Effects/FloatingText'
export default class Collectible extends Entity {
constructor(x, y, options){
super(x, y, options);
...
this.animTimer = 0;
}
update(){
super.update();
this.handleAnimation();
}
handleAnimation() {
if (!this.isCollected) return;
this.animTimer += game.delta() * 4;
this.scale.x = Ease(EasingFunctions.easeInCubic, this.animTimer, 1, -0.95);
this.scale.y = Ease(EasingFunctions.easeInCubic, this.animTimer, 1, -0.95);
this.moveTowardsPlayer();
if (this.animTimer >= 1) this.getCollected();
}
moveTowardsPlayer() {
if (!this.isCollected) return;
this.velocity.y = Smooth(this.velocity.y, 0, 8);
this.rotSpeed = Smooth(this.rotSpeed, 0, 8);
this.pos.x = Smooth(this.pos.x, game.player.pos.x, 12);
this.pos.y = Smooth(this.pos.y, game.player.pos.y, 12);
}
getCollected() {
this.shouldBeRemoved = true;
spawnParticles(game.player.pos.x, game.player.pos.y, 10, { img: this.img });
const x = game.player.pos.x;
const y = game.player.pos.y - game.player.size * 0.75;
spawnFloatingText("+1", x, y);
game.addScore(1)
playSound(game.sounds.collect);
game.player.pulse();
}
}That’s a lot of code! Let’s break it down:
So, once set our isCollected value to true, things start to happen.
In handleAnimation(), we advance the animTimer property by game.delta() * 4, then we use that animTimer value to do some EasingFunctions and shrink the scale from 1 to 0.05.
At the same time, in moveTowardsPlayer(), we’re doing several things at once:
-
this.velocity.y = Smooth(this.velocity.y, 0, 8)- Gradually disable the existing vertical velocity -
this.rotSpeed = Smooth(this.rotSpeed, 0, 8)- Start spinning wildly -
this.pos.x = Smooth(this.pos.x, game.player.pos.x, 12) -
this.pos.y = Smooth(this.pos.y, game.player.pos.y, 12)- Quickly move towards the player location
Once animTimer has gone up to 1 (in about 0.25 seconds), Easing animation will be over and that’s when the actual collecting happens with getCollected().
Then finally, in getCollected(), we:
-
Set the
shouldBeRemovedflag totrue, which lets the game know this object will be deleted in the next frame. -
Spawn 10 particles at Player’s position and give them the same image as the Collectible.
-
Spawn a
+1floating text a little above the player -
Add 1 to game score
-
Play the
collectsound. -
Call
game.player.pulse(), which resets Player’s "pulse" animation. We don’t have that animation yet, so let’s set it up! Also, remember when we set thegame.playerproperty. We finally made use of it!
Back to frontend/src/Components/Game/Entities/Player.js:
import { game } from '..'
import Entity from './Entity'
import { Smooth, Ease, EasingFunctions } from '../Utils/EasingFunctions'
export default class Player extends Entity {
constructor(x, y, options){
super(x, y, options);
...
this.animTimer = 0;
}
update(){
...
this.handleAnimation();
}
handleAnimation() {
if (this.animTimer > 1) return;
this.animTimer += game.delta();
const intensity = 0.3;
this.scale.x = Ease(EasingFunctions.easeOutElastic, this.animTimer, 1 + intensity, -intensity);
this.scale.y = Ease(EasingFunctions.easeOutElastic, this.animTimer, 1 - intensity, +intensity);
}
pulse() {
this.animTimer = 0;
}
}As you can see, it’s a similar animation setup as in Collectible.
We’re incrementing the animTimer property as long as it’s below 1, because our EasingFunctions only work for values between 0 and 1.
Then we modify the scale again, but this time we’re using the easeOutElastic function, which gives us a nice bouncy effect.
pulse() function just resets the animTimer to 0, which restarts the animation.
Now it’s looking better!
Like all good things in life, this game, too, has to come to an end somehow!
To do that, we’ll have to check if any of the collectibles went past the player and off the screen.
Let’s go back to frontend/src/Components/Game/Entities/Collectible.js and add that check:
import { game } from '..'
import Entity from './Entity'
import playSound from '../Utils/playSound'
export default class Collectible extends Entity {
...
update(){
...
this.checkIfMissed();
}
checkIfMissed() {
if (game.gameOver) return;
const isBelowScreen = this.pos.y > game.height + this.size / 2;
if (isBelowScreen) this.onMiss();
}
onMiss() {
game.loseLife();
playSound(game.sounds.loselife);
game.camera.shake(0.25, 12);
this.shouldBeRemoved = true;
}
}First, we check the Collectible’s pos.y coordinate, and if it’s higher the lower edge of the screen, we trigger the onMiss() function, where we:
-
Trigger a
loseLifeevent. The template makes sure that the game automatically ends when there are no lives left. -
Play a
loselifesound -
Shake the camera a bit to amplify that negative effect
-
Set the
shouldBeRemovedflag totrue, to make sure our object will be deleted from memory in the next frame.
|
Note
|
Deleting unused objects from memory is an especially important step in order to prevent memory leaks from prolonged gameplay sessions which can result in performance slowdown and eventually a crash |
At this stage, our game is finally playable!
But right now, it might be a little too repetitive and easy. We want to make it competitive and progressively more difficult as time passes, so our players have more incentive to compete on the leaderboard.
There’s an easy way to do that!
Remember how we had a function that handled our spawn logic? We can do something similar this time.
Let’s define some settings in frontend/src/Components/Game/preload.js first:
...
const initializeValues = () => {
...
game.difficultyIncreasePeriod = 2;
game.difficultyIncreaseTimer = 2;
game.difficultyIncreaseAmount = 0.1;
game.speedLimit = 12;
game.speedIncrease = 0.1;
}Then we can create our handler function in frontend/src/Components/Game/draw.js:
...
const draw = () => {
...
manageDifficulty();
}
const manageDifficulty = () => {
game.difficultyIncreaseTimer -= game.delta();
if (game.difficultyIncreaseTimer <= 0) {
increaseDifficulty();
game.difficultyIncreaseTimer = game.difficultyIncreasePeriod;
}
}
const increaseDifficulty = () => {
if (game.spawnPeriodMin > game.spawnPeriodLimit) {
game.spawnPeriodMin -= game.difficultyIncreaseAmount;
game.spawnPeriodMax -= game.difficultyIncreaseAmount;
}
if (game.speedMax < game.speedLimit) {
game.speedMax += game.speedIncrease;
game.speedMin += game.speedIncrease;
}
}So, using our timer we periodically increase our difficulty by changing some game settings, up to a point. It’s important to set a limit to prevent the game getting totally unplayable at some point, which most players don’t really consider a fun experience.
Every time the difficulty increases, we:
-
Slightly decrease the period at which Collectibles spawn
-
Slightly increase the falling speed of Collectibles
Congratulations!
We’ve actually finished developing our game!
Of course, you can go ahead and try modifying something, or adding a new feature. Everything’s a bonus from now on!
We’ve finished the main part of our game. But this is Koji! We don’t stop here.
We want this game to be easily remixable by other people and shared everywhere online!
Let’s explore a new section of the game template.
If you open frontend/src/Components/Remix/index.js, you’ll find some workflow already set up.
|
Note
|
You can see what your Remix menu currently looks like in the Remix tab of the Debugger, or inside the Live Preview from your web editor
|
Background Image and Music customization are already handled by the template, so we can just leave them as they are.
Luckily, since we’ve already made a generic Player class that uses imgPlayer for its image, we can take advantage of the template once more because it already has everything set up for our player to be customizable.
To test it out, try changing the Player image and going into preview mode. Our player is different now!
But what about our Collectible image? Let’s make that customizable too, using a similar workflow as with Player.
First, let’s make a State Hook that will represent our Collectible image, and assign the initial values:
...
const Component = () => {
...
const [imgCollectible, setImgCollectible] = useState("");
const [isCollectibleInvalid, setIsCollectibleInvalid] = useState("");
...
const assignInitialValues = () => {
const remixValues = Koji.remix.get();
...
setImgCollectible(remixValues.imgCollectible);
...
})
}Notice that we also created an isCollectibleInvalid state hook. We will need this to make sure the user has actually uploaded an image. We don’t want them to publish the game without one.
Then in assignInitialValues(), which is inside our useEffect Hook when the Remix components mounts, we assign the initial value from koji.json:
We also need to define some functions that will let us change or delete our collectible image:
...
const Component = () => {
...
const changeCollectible = async () => {
const image = await Koji.ui.capture.image();
if (!image || image === "") return;
setImgCollectible(image);
setIsCollectibleInvalid(false);
await Koji.remix.set({ imgCollectible: image })
updateGame();
}
const deleteCollectible = async () => {
setImgCollectible("");
await Koji.remix.set({ imgCollectible: "" })
updateGame();
}
}To capture our image, we’re using @withkoji/core package that lets us easily upload an image, paste one from a link or choose from one of the asset packs.
Koji.ui.capture.image() will return null in case the user has cancelled image selection, so we also include a simple check to make sure that’s not the case.
Then we:
-
Update our
imgCollectiblestate hook with the resulting image -
Reset the
isCollectibleInvalidstate hook -
Update our
imgCollectibleremix value with the resulting image -
Update the game
We want to update our game here because we have a running game preview behind our Remix panel. If we change something that wouldn’t be instantly previewable inside the game, this step can be skipped.
Inside deleteCollectible(), we do the same thing, except we just set our imgCollectible to an empty string instead of capturing an image.
Next, let’s render the component which represents a collectible image and lets us customize it. We can once again use the existing structure from Player.
Find the component that looks like this:
<div>
<Label>
<b>{"Player"}</b>
</Label>
<ImageButton
src={optimizeImage(imgPlayer, 120, 120)}
canDelete={canUpdateImages}
onClick={changeImgPlayer}
onDelete={deletePlayer}
isInvalid={isPlayerInvalid}
/>
</div>Make a copy just below it, still inside the <Section> component, and modify it to look like this:
<div>
<Label>
<b>{"Collectible"}</b>
</Label>
<ImageButton
src={optimizeImage(imgCollectible, 120, 120)}
canDelete={canUpdateImages}
onClick={changeCollectible}
onDelete={deleteCollectible}
isInvalid={isCollectibleInvalid}
/>
</div>Lastly, we want to modify the finish() function to prevent the user from publishing if they haven’t uploaded an image.
It already has that logic handled for imgPlayer, so we can just modify it to include imgCollectible as well:
const finish = async () => {
if (imgPlayer === "" || imgCollectible === "") {
setErrorLabel("Complete the required selections to continue.");
if (imgPlayer === "") {
setIsPlayerInvalid(true);
}
if (imgCollectible === "") {
setIsCollectibleInvalid(true);
}
return;
}
await Koji.remix.set({
backgroundImage,
music,
title,
})
Koji.remix.finish();
}|
Note
|
We don’t need to explicity set imgPlayer and imgCollectible inside the Koji.remix.set() function, because we’ve already done it with our onChange handlers. Setting them here as well won’t make any difference in the final result.
|
That’s our Remix mode finished! We’ve just added the ability for users to change the Collectible image!
The very last thing we need to do before releasing our new game into the wild, is to customize the thumbnail preview, so when users share their remixes, they get nice little previews that people will click on!
Basically, whatever we render inside of frontend/src/Components/Screenshot/index.js will be rendered in the thumbnail preview.
|
Note
|
You can see what your thumbnail preview currently looks like in the Thumbnail tab of the Debugger, or inside the Live Preview from your web editor
|
By default, this component already shows imgPlayer in the center.
It looks something like this:
<FeaturedImage
src={optimizeImage(remixValues.imgPlayer, 150, 150)}
rotation={randomRange(-30, 30)}
style={{
left: '50%',
top: '50%'
}}
/>Let’s move the player down a bit. To do that, just change the top value to something like 80%:
<FeaturedImage
src={optimizeImage(remixValues.imgPlayer, 150, 150)}
rotation={randomRange(-30, 30)}
style={{
left: '50%',
top: '80%'
}}
/>Now, let’s add some Collectible images by duplicating the player’s FeaturedImage below, and changing their positions around. They don’t have to be exactly the same, you can play around and see what you like best. You can even randomize their positions if you want!
<FeaturedImage
src={optimizeImage(remixValues.imgCollectible, 150, 150)}
rotation={randomRange(-30, 30)}
style={{
left: '35%',
top: '25%'
}}
/>
<FeaturedImage
src={optimizeImage(remixValues.imgCollectible, 150, 150)}
rotation={randomRange(-30, 30)}
style={{
left: '75%',
top: '35%'
}}
/>
<FeaturedImage
src={optimizeImage(remixValues.imgCollectible, 150, 150)}
rotation={randomRange(-30, 30)}
style={{
left: '45%',
top: '50%'
}}
/>Once done, your thumbnail preview should look something like this:
That’s it, we just created a fun and remixable Koji game!
All that’s left to do is to publish it from the Publish Now menu, and share it with your friends so they can compete for the top leaderboard spot!
Of course, the project is now your playground. You can go back to modify or add new features, make improvements, anything you can imagine!
Here are some features you can try adding:
-
Add horizontal speed to Collectibles
-
Multiple Collectible images
-
Flip the rules so that you have to avoid everything
-
Add more animations to collectibles
-
Have the player be able to move in all directions, not just horizontally
-
Add keyboard controls
-
Add projectiles so you can shoot the falling objects
-
Make an MMORPG out of this! (just kidding)
You can also post your new game to our Discord Server, along with any suggestions and thoughts on how you think we can make this tutorial even better!
You can find the finished game created by following this tutorial here.









