This is the repo for the game:
Produced for Gamedev.js Jam 2024
.
├── asprite: Asesprite files used to produce art assets like spritesheets.
├── beepbox: Links to all beepbox songs.
├── bundler: Use by bun.js to bundle the code code in "src" and make it available.
├── dist: Distributed files.
├── example: Where the game is packaged.
│ ├── assets: All assets (art, music, sound) used by the game.
│ ├── dist: Packaged JS files (bundled from typescript)
│ ├── json: All levels in JSON format.
│ ├── node_modules: dependencies
│ └── src: Just to pass source from outer "src" folder.
├── node_modules: dependencies
├── ...
└── src
├── config: Just the url to the API for fetching NPC dialog
├── example: Sample game started from tutorial. Not used.
└── hischool: The entire game (initially I was planning a High School setting).
The repo follows the bun-template github template for TypeScript / React project, in which a package is built within the “src” directory, and the “example” directory uses the package as a library to show a demo of that package.
In this case, the core logic is in the “src” directory, while the rest of the game including the assets are all in the example directory.
This repo us built using bun.js. Install from here.
curl -fsSL https://bun.sh/install | bash
Then run:
bun i
Execute:
./sample.sh
This builds the necessary file and start the server hosting the game, then starts the server from the “example” folder.
Then open the page in a browser “http://localhost:3000”.
The theme of the gamejam is: POWER.
The game is a platform puzzle type where you control a troll that can grant supernatural powers to humans, by picking up and tossing powerups or placing them in their path.
The troll has to pick up a key and reach a door. He’s not allowed to touch any human.
The game has various features detailed below. Furtherdowm, I will explain the implementation.
Each NPC is composed of various body parts that are randomized at the start of each level. As such, every NPC look different. They can be male or female, wear hat or not, have different hairstyles, wear skirt, pants, or just underwear. They also have different skin colors.
Each powerup is acquired by a human one at a time. Acquiring a new powerup cancels the effect of a previous one.
Here are the different powerups available:
Every few seconds, a random NPC makes a comment. It is most likely related to a recent event: Just saw the troll come and disappear, did a high jump, pushed a heavy rock, shrunk down, fell into the water, etc…
To generate the dialog, the history of events from an NPC is accumulated then passed on to OpenAI API to inform what happened to that NPC, and an appropriate response from the NPC is returned. The description of the environment is also pased, if it’s noteworthy. (like the “pizza” level).
Some surprising events cause the NPC to jump and pause for a moment. (saw a troll, just pushed a heavy rock). The NPC also stops a walk for a bit before talking.
The NPC response is passed to the browser’s voice synthesizer, which will speak the response along with showing the text.
The NPC voice is chosen at random at the beginning of each level. It doesn’t always match the NPC look. Given that each voice has an association with a language, that information is passed to OpenAI, and the NPC ends up using words from their native language along with their response. (For ex: French NPCs love to say “Sacrebleu”, and Mexican NPCs often say “Aye Caramba”.).
Each level is composed of several unique tiles, ranging from:
The level editor enables level editing while the game is running, to allow immediate adjustments.
Pressing SHIFT + Clicking duplicates an element, allowing the editor to place more elements.
Pressing DELETE after selecting an item removes it.
Some level configurations are set manually in the json files: Decor overlay, if the level has pizza, gold walls, snail or cat, if it’s locked (editor OFF), and what’s the next level after completing it.
NPCs are composed of two categories of elements:
enum FaceEnum {
SHAPE = 0,
MOUTH = 1,
NOSE = 2,
EYES = 3,
EYELASHES = 4,
HAIR = 5,
HAT = 6,
};
Those elements have no animation, so each one is unique. One of each is chosen as random to compose the face. Each of those elements is overlayed on top of each other to produce a unique face when combined. There’s also a random tint applied to the “shape” to change skin color.
When the NPC needs to talk, we alternate between the NPC’s mouth, and the graphics showing the mouth opened. If the NPC’s default mouth is opened, we alternate with a random mouth.
enum BodyEnum {
BODY = 0,
UNDERWEAR = 1,
SHIRT = 2,
PANTS = 3,
SKIRT = 4,
SMALLSHOES = 5,
SHOES = 6,
};
Body and underwear will always be present. The tint of face’s shape is applied to the body for consistency of the skin color.
Each body part is animated in sync, when the character is walking or standing still.
The function that sets up the look is function randomSprite()
. We pass a seed, stored on each NPC, which will ensure the same seed passed will result to the same character customization. This is needed because we apply random tint when a character acquires a power-up, then we need to restore to normal once its done.
When a NPC collides with a power up, that powerup is attached to the NPC (shown over the head). The NPC’s various behavior is then modified depending on the powerup. This is done through custom code applied throughout.
INITIAL_SCALE - .7
. Initial scale is around 1, so the eventual size becomes roughly 1/3. That value gets restored when the NPC loses that power.As the game progresses, the NPC accumulates a history of events. Each event is added with the addHistory()
function.
When it is time for an NPC to talk, a random one is chosen, then the history is passed to a rest API which URL is defined in OPEN_AI_URL.
The implementation of that API is at https://github.com/jacklehamster/open-ai-npc.
const url = `${OPEN_AI_URL}?dictionary=${JSON.stringify(dico)}&situation=${HumanEvent.LANG}.${situation}&seed=${seed ?? ""}&jsonp=fetchAIResponse`;
DICO
in human-events.ts
. The enums from HumanEvent
is mapped to each phrase, then a history of what happened is composed and passed to the API.situation
, we pass the entire dictionary of phrases, and situations will simply be a series of numbers (still separated with dots), corresponding to the index in the dictionary.
There’s a specific reason for doing that, but it has not been yet implemented.The idea is that situation can be constantly different, but it will eventually be just a series of numbers. Meanwhile, DICO will always remain the same. That means we could potentially take the MD5 of DICO and pass it to the server, and thus the server can whiltelist that MD5. This could prevent attackers from calling directly the server with random junk, thus dumping garbage to the OpenAI API (which might not be a really big deal).
The implementation could be changed to just pass the strings direcetly into situation
, I don’t think the DICO
implementation is really necessary.
<script>
tag, which will call a javascript function of our choice.
This is a trick to bypass cross-domain restriction. I just didn’t have time to figure out this whole domain restriction mess during the gamejam.Once the rest API responds a speech for the NPC, that speech is passed into the Speech Synthesizer which will make the computer “speak” the sentence. We also use utterance “boundary” event to make the words written on the screen match the timing of the speech.
Fetch the source from: https://github.com/jacklehamster/open-ai-npc
Then start the server using the ./start.sh
command, which will run the server locally on http://localhost:3001
Then change the OpenAI URL from https://open-ai-npc.onrender.com/comment/ to http://localhost:3001/comment/.
There’s two locations to change OPEN_AI_URL
. One in constants.ts
and one in example/index.ts
.
When fetchAI is called, the “jsonp” parameter can be passed as true or false. When passed as true, the API call is made using jsonp (use a <script>
tag which will call the function fetchAIResponse
.). If the server is local, that’s not necessary, so the API call can just be made through the fetch()
function.
When the game is hosted locally on a server, we enable the level editor.
The local server contains API for saving elements from the game diretly into the mapN.json
JSON that was used for configuring the level.
Every time an element is moved, the commit()
function is called, passing the ID of the element moved. If a new element is created, a new ID for it is generated, so the server will know to create a new element in the mapN.json
file.
During the game, there are some UI components that can be enabled in edit mode, allowing an editor to drag elements to move or resize them. Those are just implemented as part of the game, and enabled only in edit mode. Thus, the game can be edited when it is played. An editor can try a level, find out that a particular jump is impossible, then just resize, place the troll back to its original position and try again. This allows for some very fast iteration.
The game has unique art for each level. While it’s time consuming, it does make some art development a bit easier.
The way each level is designed, is that I first remove the “overlay” from a map, then place the elements around to make the level solvable. Afterwards, once the platforms are clearly defined, I dump a screenshot of it into “Aseprite”, then draw some art on top of the platform. Then I save that as a PNG. I then set that as the overlay of the level, so every piece of art will just cover the platforms in the game.
{
"overlay": "assets/overlay1.png",
...
}
Each level song is an mp3 played by phaser, depending on the level:
preload() {
this.load.audio('main', ['assets/troll-song.mp3']);
...
}
create()
{
this.music = this.sound.add(parseInt(level) % 2 === 1 ? 'main' : 'main2');
this.music.loop = true;
this.music.play();
...
}
For sound effect, the zzfx is called directly from the code provided from the web tool.
zzfx(...[1.52, , 1177, .23, .09, .09, 1, 1.41, 12, , , , , .4, , , .06, .25, .11, .11]); // Random 348
Cut scenes are implemented outside of Phaser, for simplicity.
The code is in example/index.html
.
The cutscene is defined within the structure in variable CUT_SCENES
.
Each element is a string with:
[
imageUrl,
textToDisplay,
urlOfMusic,
loopTheMusic, // true or false
fadeToTheImage, // true or false. True causes the previous image to fade out, the current one to fade in.
]
Repos generated using the bun-template all use the same method to publish.
Using the following command, the repo gets commited, pushed to Github, then published to “npmjs”.
./auto-publish.sh
The script generates its own commit message, so to get a meaningful message, it’s best to first commit manually.
While publishing to npmjs might not make sense for a standalone game, this is useful when producing reusable packages that can be imported in various javascript projects.
Game portals require a single zip file which contain the entire website of the game, starting with the “index.html” file. That’s the “example” folder.
Server code is ignored by game portals, so they don’t need to be removed from it. The “node_modules” must be deleted before zipping the “example” folder because they contain a large amount of folders, that are also not needed by the website.
https://jacklehamster.github.io/phaser-game/example/
https://jacklehamster.itch.io/power-troll
https://github.com/jacklehamster/phaser-game/