PC Games

Orb
Lasagne Monsters
Three Guys Apocalypse
Water Closet
Blob Wars : Attrition
The Legend of Edgar
TBFTSS: The Pandoran War
Three Guys
Blob Wars : Blob and Conquer
Blob Wars : Metal Blob Solid
Project: Starfighter
TANX Squadron

Android Games

DDDDD
Number Blocks
Match 3 Warriors

Tutorials

2D shoot 'em up
2D top-down shooter
2D platform game
Sprite atlas tutorial
Working with TTF fonts
2D adventure game
Widget tutorial
2D shoot 'em up sequel
2D run and gun
Roguelike
Medals (Achievements)
2D turn-based strategy game
2D isometric game
2D map editor
2D mission-based shoot 'em up
2D Santa game
2D split screen game
SDL 1 tutorials (outdated)

Latest Updates

SDL2 Versus game tutorial
Wed, 20th March 2024

Download keys for SDL2 tutorials on itch.io
Sat, 16th March 2024

The Legend of Edgar 1.37
Mon, 1st January 2024

SDL2 Santa game tutorial 🎅
Thu, 23rd November 2023

SDL2 Shooter 3 tutorial
Wed, 15th February 2023

All Updates »

Tags

android (3)
battle-for-the-solar-system (10)
blob-wars (10)
brexit (1)
code (6)
edgar (9)
games (43)
lasagne-monsters (1)
making-of (5)
match3 (1)
numberblocksonline (1)
orb (2)
site (1)
tanx (4)
three-guys (3)
three-guys-apocalypse (3)
tutorials (17)
water-closet (4)

Books


The Red Road

For Joe Crosthwaite, surviving school was about to become more than just a case of passing his exams ...

Click here to learn more and read an extract!

« Back to tutorial listing

— Mission-based 2D shoot 'em up —
Part 20: Intermission: Loading / Saving

Note: this tutorial assumes knowledge of C, as well as prior tutorials.

Introduction

We've reached the the point where we're ready to implement loading and saving into our game. Eventually, we'll allow loading from the title screen, and also the ability to "Continue ..." based on the most recent save. Right now, however, we're offering it in the Intermission screen.

Extract the archive, run cmake CMakeLists.txt, followed by make, and then use ./shooter3-20 to run the code. You will see a window open like the one above. Use the mouse to control the cursor. Click on an empty slot to enable the Save button. Clicking on a used save slot will enable the Load, Save, and Delete buttons. Clicking Load will load the game data, and restart the Intermission section. Clicking Delete on a used save slot will delete the associated data. Games are saved to files called save01.json, save02.json, etc. Once you're finished, close the window to exit.

Inspecting the code

Well, this part is a bit larger than one might expect, but mostly comes down to all the JSON work that goes into producing the save data (and also reading it back in again). We've added a few extras to this part, to help make it more complete (such as the transition.c file).

Let's go ahead and see what's been added and changed, starting with defs.h:


#define NUM_SAVE_SLOTS 5

#define SAVE_SLOT_EMPTY   -1

We've created two new defines here, NUM_SAVE_SLOTS is the number of save slots we'll support, while SAVE_SLOT_EMPTY is a special value to mark as a save slot as not being in use.

Over to structs.h now, where we've made some additions and updates:


typedef struct
{
	int           slot;
	char          dateTimeStr[MAX_NAME_LENGTH];
	unsigned long time;
	SDL_Rect      rect;
} SaveSlot;

We've added a struct called SaveSlot, that will represent meta data for a saved game. `slot` is the save slot number, dateTimeStr is a string that will hold the formatted time and date the game was saved, `time` is actual time in milliseconds from the epoch that the game was saved, and `rect` is the rectangular position of the save slot on screen, used with mouse interactions.

Next, we've updated Mission:


struct Mission
{
	Comms    commsHead;
	int      complete;
	Mission *next;
};

We've added a variable here called `complete`, to mark whether we've completed this mission. This is a pre-empt for later parts, but something our save game data will support.

Finally, we've updated Game:


typedef struct
{
	int catnip;
	int saveSlot;
	// snipped
} Game;

We've added a variable called saveSlot. This is both to help mark the current save slot the player is using, and, later, to help support autosaving.

Now for loadSave.c, which is the new compilation unit that we've created to show our save screen. There's quite a number of functions here, but a lot of them are easy to understand.

Starting with initLoadSave:


void initLoadSave(void)
{
	int       i, y;
	SaveSlot *s;
	Widget   *w;

	populateSaveSlots(saveSlots);

	y = 200;

	selectedSaveSlot = NULL;

	for (i = 0; i < NUM_SAVE_SLOTS; i++)
	{
		s = &saveSlots[i];

		s->rect.w = 400;
		s->rect.h = 50;
		s->rect.y = y;
		s->rect.x = (SCREEN_WIDTH - s->rect.w) / 2;

		y += s->rect.h + 20;
	}

	saveWidget = getWidget("save", "loadSave");
	saveWidget->x = (SCREEN_WIDTH - saveWidget->w) / 2;
	saveWidget->y = SCREEN_HEIGHT - 300;
	saveWidget->action = save;
	loadWidget->disabled = 1;

	loadWidget = getWidget("load", "loadSave");
	loadWidget->x = saveWidget->x - (loadWidget->w + 50);
	loadWidget->y = SCREEN_HEIGHT - 300;
	loadWidget->action = load;
	loadWidget->disabled = 1;

	deleteWidget = getWidget("delete", "loadSave");
	deleteWidget->x = saveWidget->x + (saveWidget->w + 50);
	deleteWidget->y = SCREEN_HEIGHT - 300;
	deleteWidget->action = delete;
	deleteWidget->disabled = 1;

	w = getWidget("cancel", "loadSave");
	w->x = (SCREEN_WIDTH - w->w) / 2;
	w->y = SCREEN_HEIGHT - 200;
	w->hidden = 1;

	messageTimer = 0;
}

This is the setup function, that prepares the screen. The first thing we do is call a function named populateSaveSlots, passing over an array of SaveSlots (of NUM_SAVE_SLOTS in size) called saveSlots (static in loadSave.c). Next, we NULL a variable called selectedSaveSlot that will be used to track the save slot we've selected, and then we setup each SaveSlot's `rect`, to position it on screen. We setup our widgets, and then finally set a variable called messageTimer to 0. This variable is used to control displaying messages to the player. Note that our Load, Save, and Delete widgets are all disabled by default, until we select a slot.

A standard setup function. Next, we have doLoadSave:


void doLoadSave(void)
{
	int       i;
	SaveSlot *s;

	messageTimer = MAX(messageTimer - app.deltaTime, 0);

	hoverSaveSlot = NULL;

	for (i = 0; i < NUM_SAVE_SLOTS; i++)
	{
		s = &saveSlots[i];

		if (collision(app.mouse.x, app.mouse.y, 1, 1, s->rect.x, s->rect.y, s->rect.w, s->rect.h))
		{
			hoverSaveSlot = s;

			if (app.mouse.buttons[SDL_BUTTON_LEFT])
			{
				app.mouse.buttons[SDL_BUTTON_LEFT] = 0;

				selectedSaveSlot = s;

				updateButtons();
			}
		}
	}

	doWidgets("loadSave");
}

This is the main function that drives our saving screen. As you can see, it's very similar to some other functions that have come before. We're decreasing the value of messageTimer, and also testing if we're hover over any of our save slots, or have clicked on them. If we click on any, we set selectedSaveSlot to the one we clicked on, and then call a function named updateButtons. The last thing we do is process our widgets.

The updateButtons function comes next:


static void updateButtons(void)
{
	loadWidget->disabled = deleteWidget->disabled = selectedSaveSlot->time == SAVE_SLOT_EMPTY;
	saveWidget->disabled = 0;
}

Here, we're disabling or enabling the Load and Delete widgets, based on whether the selected save slot is empty. This prevents us from attempting to load a non-existent game (including one that we might have just deleted!). We're enabling the Save button, though, since once we've selected a slot, we're always allowed to save.

That's our main logic done. We'll come to the widget action functions in a moment. For now, let's look at our rendering code. The drawLoadSave function handles this:


void drawLoadSave(void)
{
	int       i;
	SaveSlot *s;
	SDL_Rect *r;
	SDL_Color c;

	app.fontScale = 1.5;

	for (i = 0; i < NUM_SAVE_SLOTS; i++)
	{
		s = &saveSlots[i];
		r = &s->rect;

		c.r = c.g = c.b = 0;

		if (s == hoverSaveSlot || s == selectedSaveSlot)
		{
			if (s->time == SAVE_SLOT_EMPTY)
			{
				c.r = 64;
				c.g = 64;
				c.b = 64;
			}
			else
			{
				c.r = 32;
				c.g = 64;
				c.b = 128;
			}

			if (s == selectedSaveSlot)
			{
				c.r *= 1.5;
				c.g *= 1.5;
				c.b *= 1.5;
			}
		}

		drawRect(r->x, r->y, r->w, r->h, c.r, c.g, c.b, 255);

		c.r = c.g = c.b = 128;

		if (s->slot == game.saveSlot)
		{
			c.r = c.b = 0;
			c.g = 255;
		}

		drawOutlineRect(r->x, r->y, r->w, r->h, c.r, c.g, c.b, 255);

		drawText(s->dateTimeStr, r->x + 13, r->y + 5, 0, 0, 0, TEXT_ALIGN_LEFT, 0);

		drawText(s->dateTimeStr, r->x + 10, r->y + 2, 255, 255, 255, TEXT_ALIGN_LEFT, 0);
	}

	app.fontScale = 1;

	if (messageTimer > 0)
	{
		drawText(message, SCREEN_WIDTH / 2, loadWidget->y - 50, 255, 255, 160, TEXT_ALIGN_CENTER, 0);
	}

	drawWidgets("loadSave");
}

This function is actually less complicated than it might at first appear. In short, what is it doing is looping through all the save slots and drawing a coloured rectangle (using the SaveSlot's `rect`), based on whether the save slot is empty, in use, selected, being hovered over, etc. This is very much like our intermission icons and our shop. With the rectangle and outline drawn, we're then drawing the save slot's dateTimeStr. Notice we're rendering it twice, the first time in black, and slightly offset. This is to create a drop shadow, to aid with readability. Lastly, we're testing the value of messageTimer, and drawing our message (static in loadSave.c). We're also processing our widgets.

Pretty simple so far, especially since we've seen this sort of thing done a few times before. Now for all our widget functions. First up, we have the `load` function:


static void load(void)
{
	loadGame(selectedSaveSlot->slot);

	initIntermission();
}

Here, we're calling a function named loadGame (done in game.c - we'll see this later on), passing over the currently selected save slot. With the load complete, we're then calling initIntermission, to reset the intermission screen.

Next up is the `save` function:


static void save(void)
{
	game.saveSlot = selectedSaveSlot->slot;

	saveGame();

	populateSaveSlots(saveSlots);

	sprintf(message, "Saved game #%d", selectedSaveSlot->slot + 1);

	messageTimer = FPS * 2;

	updateButtons();
}

A little more going on here. When we save a game, we're setting Game's saveSlot to the value of the selected save slot's `slot`, before then saving the game, via a call to saveGame (done in game.c). With that done, we'll repopulate our save slots, to reflect the new save data, prepare a message to inform the player about what's happened, and then update our buttons. A series of logical steps, to keep everything in sync!

The `delete` function follows a similar path:


static void delete (void)
{
	deleteGame(selectedSaveSlot->slot);

	populateSaveSlots(saveSlots);

	sprintf(message, "Deleted game #%d", selectedSaveSlot->slot + 1);

	messageTimer = FPS * 2;

	updateButtons();
}

We're calling deleteGame (done in game.c), once again passing over the slot number of the selected slot, and then updating our save screen as with the save function.

That's it for loadSave.c. What we need to do now is look at our actual loading and saving function. As previously stated, this is being done in game.c.

We'll start with initGame:


void initGame(void)
{
	Planet *p;

	memset(&game, 0, sizeof(Game));

	game.saveSlot = -1;

	game.kite.health = game.kite.maxHealth = 10;
	game.kite.reload = MIN_KITE_RELOAD;
	game.kite.damage = 1;
	game.kite.output = 1;

	for (p = intermission.planetHead.next; p != NULL; p = p->next)
	{
		if (p->mission != NULL)
		{
			p->mission->complete = 0;
		}
	}
}

We've updated this function to reset all our planets missions to be incomplete. Since this is a new game, we'll want to ensure no missions that had been completed previously remain so.

Next up is loadGame, a new function:


void loadGame(int saveSlot)
{
	char   *data, filename[MAX_FILENAME_LENGTH];
	cJSON  *root, *gameJSON, *node, *child;
	Planet *p;

	initGame();

	sprintf(filename, SAVE_GAME_FILENAME, saveSlot);

	data = readFile(filename);

	root = cJSON_Parse(data);

	gameJSON = cJSON_GetObjectItem(root, "game");

	game.catnip = cJSON_GetObjectItem(gameJSON, "catnip")->valueint;
	game.saveSlot = saveSlot;

	node = cJSON_GetObjectItem(gameJSON, "kite");
	game.kite.health = cJSON_GetObjectItem(node, "health")->valueint;
	game.kite.maxHealth = cJSON_GetObjectItem(node, "maxHealth")->valueint;
	game.kite.reload = cJSON_GetObjectItem(node, "reload")->valueint;
	game.kite.damage = cJSON_GetObjectItem(node, "damage")->valueint;
	game.kite.output = cJSON_GetObjectItem(node, "output")->valueint;
	game.kite.secondaryWeapon = lookup(cJSON_GetObjectItem(node, "secondaryWeapon")->valuestring);
	for (child = cJSON_GetObjectItem(node, "ownedSecondaryWeapons")->child; child != NULL; child = child->next)
	{
		game.kite.ownedSecondaryWeapons[lookup(child->valuestring)] = 1;
	}

	for (child = cJSON_GetObjectItem(gameJSON, "completedMissions")->child; child != NULL; child = child->next)
	{
		for (p = intermission.planetHead.next; p != NULL; p = p->next)
		{
			if (p->mission != NULL && strcmp(p->name, child->valuestring) == 0)
			{
				p->mission->complete = 1;
			}
		}
	}

	node = cJSON_GetObjectItem(gameJSON, "stats");
	game.stats.shotsFired = cJSON_GetObjectItem(node, "shotsFired")->valueint;
	game.stats.shotsHit = cJSON_GetObjectItem(node, "shotsHit")->valueint;
	game.stats.catnipCollected = cJSON_GetObjectItem(node, "catnipCollected")->valueint;
	game.stats.catnipSpent = cJSON_GetObjectItem(node, "catnipSpent")->valueint;
	game.stats.ammoUsed = cJSON_GetObjectItem(node, "ammoUsed")->valueint;
	game.stats.enemiesDestroyed = cJSON_GetObjectItem(node, "enemiesDestroyed")->valueint;
	game.stats.damageTaken = cJSON_GetObjectItem(node, "damageTaken")->valueint;
	game.stats.missionsStarted = cJSON_GetObjectItem(node, "missionsStarted")->valueint;
	game.stats.missionsCompleted = cJSON_GetObjectItem(node, "missionsCompleted")->valueint;
	game.stats.powerupsCollected = cJSON_GetObjectItem(node, "powerupsCollected")->valueint;
	game.stats.timePlayed = cJSON_GetObjectItem(node, "timePlayed")->valueint;

	cJSON_Delete(root);

	free(data);
}

Once more, this looks like a meaty function, but all that's really going on is that we're loading JSON and then using the data to populate our Game object. We call initGame first, to reset it into a pristine state. Most of what's happening should be clear, but one thing worth mentioning is that we're loading an array of missions that have been compelted. For this purpose, we're looping through all our planets, finding any that have a mission attached, comparing the name of the planet against the value of the JSON node, and marking the planet's mission as complete.

SAVE_GAME_FILENAME is a define in game.c, that specifies the name and format of our saved game, so that we can be consistent amongst all our functions.

Turning now to saveGame:


void saveGame(void)
{
	char    filename[MAX_FILENAME_LENGTH], *out;
	cJSON  *root, *node, *array, *gameJSON;
	Planet *p;
	int     i;

	if (game.saveSlot != -1)
	{
		gameJSON = cJSON_CreateObject();

		cJSON_AddNumberToObject(gameJSON, "catnip", game.catnip);

		node = cJSON_CreateObject();
		cJSON_AddNumberToObject(node, "health", game.kite.health);
		cJSON_AddNumberToObject(node, "maxHealth", game.kite.maxHealth);
		cJSON_AddNumberToObject(node, "reload", game.kite.reload);
		cJSON_AddNumberToObject(node, "damage", game.kite.damage);
		cJSON_AddNumberToObject(node, "output", game.kite.output);
		cJSON_AddStringToObject(node, "secondaryWeapon", getLookupName("SW_", game.kite.secondaryWeapon));
		array = cJSON_CreateArray();
		for (i = 0; i < SW_MAX; i++)
		{
			if (game.kite.ownedSecondaryWeapons[i])
			{
				cJSON_AddItemToArray(array, cJSON_CreateString(getLookupName("SW_", i)));
			}
		}
		cJSON_AddItemToObject(node, "ownedSecondaryWeapons", array);
		cJSON_AddItemToObject(gameJSON, "kite", node);

		array = cJSON_CreateArray();
		for (p = intermission.planetHead.next; p != NULL; p = p->next)
		{
			if (p->mission != NULL && p->mission->complete)
			{
				cJSON_AddItemToArray(array, cJSON_CreateString(p->name));
			}
		}
		cJSON_AddItemToObject(gameJSON, "completedMissions", array);

		node = cJSON_CreateObject();
		cJSON_AddNumberToObject(node, "shotsFired", game.stats.shotsFired);
		cJSON_AddNumberToObject(node, "shotsHit", game.stats.shotsHit);
		cJSON_AddNumberToObject(node, "catnipCollected", game.stats.catnipCollected);
		cJSON_AddNumberToObject(node, "catnipSpent", game.stats.catnipSpent);
		cJSON_AddNumberToObject(node, "ammoUsed", game.stats.ammoUsed);
		cJSON_AddNumberToObject(node, "enemiesDestroyed", game.stats.enemiesDestroyed);
		cJSON_AddNumberToObject(node, "damageTaken", game.stats.damageTaken);
		cJSON_AddNumberToObject(node, "missionsStarted", game.stats.missionsStarted);
		cJSON_AddNumberToObject(node, "missionsCompleted", game.stats.missionsCompleted);
		cJSON_AddNumberToObject(node, "powerupsCollected", game.stats.powerupsCollected);
		cJSON_AddNumberToObject(node, "timePlayed", game.stats.timePlayed);
		cJSON_AddItemToObject(gameJSON, "stats", node);

		cJSON_AddNumberToObject(gameJSON, "date", time(NULL));

		root = cJSON_CreateObject();
		cJSON_AddItemToObject(root, "game", gameJSON);

		out = cJSON_Print(root);

		sprintf(filename, SAVE_GAME_FILENAME, game.saveSlot);

		writeFile(filename, out);

		cJSON_Delete(root);

		free(out);
	}
}

Again, this is just standard JSON object building and saving. Worth mentioning is how we're looping through all our planets, looking for those with a mission that has been completed, and adding the name of the planet to a JSON array. This allows it to be reloaded later on in the loadGame function. One thing we're doing extra here is that we're adding a field called "date", using the system time (in milliseconds) to log the time the save was created, so that we can display it to the player. Doing this is better than relying on the file time, that can change if the files are copied, etc. Otherwise, there's nothing out of the ordinary here, there's just a lot of it..!

The deleteGame function follows:


void deleteGame(int slot)
{
	char filename[MAX_FILENAME_LENGTH];

	sprintf(filename, SAVE_GAME_FILENAME, slot);

	unlink(filename);
}

As expected, this function deletes the save file identified by `slot`, calling unlink to actually remove the file.

Finally, we have the populateSaveSlots function:


void populateSaveSlots(SaveSlot *slots)
{
	int       i;
	char      filename[MAX_FILENAME_LENGTH], *data;
	cJSON    *root, *node;
	SaveSlot *s;
	time_t time;

	for (i = 0; i < NUM_SAVE_SLOTS; i++)
	{
		s = &slots[i];
		s->slot = i;

		sprintf(filename, SAVE_GAME_FILENAME, i);

		if (fileExists(filename))
		{
			data = readFile(filename);

			root = cJSON_Parse(data);

			node = cJSON_GetObjectItem(root, "game");

			s->time = cJSON_GetObjectItem(node, "date")->valueint;

			time = s->time;

			strftime(s->dateTimeStr, MAX_NAME_LENGTH, "%d %b %Y, %H:%M", localtime(&time));

			free(data);

			cJSON_Delete(root);
		}
		else
		{
			s->time = SAVE_SLOT_EMPTY;
			STRCPY(s->dateTimeStr, "(Empty)");
		}

		SDL_LogMessage(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO, "Save Slot #%d: %s", i + 1, s->dateTimeStr);
	}
}

We've seen this called a few times in loadSave.c. What it does is look for all available save files (calling fileExists in util.c) to check for their existence. When one is found, it will load the JSON data in the file, and read the "date" value. We then use that value to set the saveSlot's dateTimeStr value. We're doing this with the help of strftime, found in the time.h header. If there is no save game available, we'll set the save slot's time to SAVE_SLOT_EMPTY (-1), and set the dateTimeStr as "(Empty)", to show it is not in use.

That's it for loadSave.c and game.c. Now we just need to incorporate this into our intermission screen.

So, over to intermission.c, where we've updated initIntermission:


void initIntermission(void)
{
	int i, x;

	startTransition();

	// snipped

	initLoadSave();

	// snipped

	endTransition();
}

We're calling three new function here - startTransition, initLoadSave, and endTransition. We'll come to startTransition and endTransition at the end. For now, we'll move on to the update to `logic`:


static void logic(void)
{
	// snipped

	switch (section)
	{
		case IS_PLANETS:
			doPlanets();
			break;

		case IS_COMMS:
			doComms();
			break;

		case IS_SHOP:
			doShop();
			break;

		case IS_STATS:
			doStats();
			break;

		case IS_LOAD_SAVE:
			doLoadSave();
			break;

		default:
			break;
	}
}

We've added in the IS_LOAD_SAVE case statement, to call doLoadSave. We've then also updated `draw`:


static void draw(void)
{
	drawBackground(background);

	drawStarfield();

	switch (section)
	{
		case IS_PLANETS:
			drawPlanets();
			break;

		case IS_COMMS:
			drawComms();
			break;

		case IS_SHOP:
			drawShop();
			break;

		case IS_STATS:
			drawStats();
			break;

		case IS_LOAD_SAVE:
			drawLoadSave();
			break;

		default:
			break;
	}

	drawSectionIcons();
}

We've added in the IS_LOAD_SAVE case statement, to call drawLoadSave.

Before we finish, let's look at the startTransition and endTransition functions. These functions live in transition.c, and are responsible for briefly clearing our screen before drawing things again. This will be added to a few places in our game, so that going from the intermission to the mission, for example, isn't an instant change, and gives the player a chance to appreciate something different is happening.

So, over to transition.c. startTransition comes first:


void startTransition(void)
{
	int oldVal;

	oldVal = app.mouse.showCursor;

	app.mouse.showCursor = 0;

	transitionStartTime = SDL_GetTicks();

	prepareScene();

	clearInput();

	presentScene();

	app.mouse.showCursor = oldVal;
}

To begin with, we're storing the state of the mouse's cursor, before then turning it off. Next, we're keeping a track of the time the transition started. We're then calling functions to clear the screen and the input. We're then restoring the mouse's showCursor to its previous state (although, nothing will be drawn yet).

The endTransition function looks like this:


void endTransition(void)
{
	long elapsed;

	elapsed = 500 - (SDL_GetTicks() - transitionStartTime);

	if (elapsed > 0)
	{
		SDL_Delay(elapsed);
	}

	app.deltaTime = 0;
}

We're testing to see how long it has been between the calls to startTransition and the call to endTransition, deducting this from 500, and assigning the result to a variable called `elasped`. If it's been less than 500 milliseconds (half a second), we'll call SDL_Delay, feeding in the remaining time to wait (elasped). We'll then reset app's deltaTime so stuff doesn't suddenly play catch up, and everything will resume. Taking initIntermission as the working example - we call startTransition at the top of the function and endTransition at the bottom. In between, we're doing some setup. Most of the time, the setup will take much less than 500 milliseconds, and so we'll only wait that long. However, in a much larger game, with more going on, endTransition could end immediately.

And that's it for loading and saving. We've got just one section left to create, and that's our options screen. With our options done, we'll be able to redefine controls, adjust the sound and music, and also specify if we want to automatically save our game. This will then mark the end of the intermission section, and we'll be able to start on the main game loop.

Purchase

The source code for all parts of this tutorial (including assets) is available for purchase:

From itch.io

It is also available as part of the SDL2 tutorial bundle:

Mobile site