https://foon.uk/how-flash-2022/ [foon] How I still use Flash in 2022 When Adobe killed Flash Player in 2020, I didn't want my Flash games to disappear forever. I've been making games on and off my whole life, but people seemed to like the Hapland ones in particular, so I thought it might be nice to fix them up for a Steam release. I could draw some better graphics, improve the frame rate and resolution, and maybe add some extra secrets and such. [hap2c]Hapland 2 The problem is, the Hapland games are very much Flash games. The graphics are drawn in Flash, the code was written in Flash, all the animations are done in the Flash timeline. These games have Flash in their bones. How was I going to do this? Failed attempt #1 The first thing I tried was having Flash export the games as executables. This would have made this a very short article but it fails because performance is about as bad as it was in 2005. I want to make a good thing that runs at contemporary frame rates. I wanted to be free of Flash Player. Failed attempt #2 Secondly, I spent far too much time fiddling around with Adobe AIR, a desktop runtime for Flash, and Starling, a library that draws your Flash scene on the GPU. I gave up on this in the end, partly becase AIR is buggy and terrible, but also because I don't really want to end up with a weird Adobe thing at the end of all this; I want to have my own thing that I can do what I want with. What if I want to port to Linux? I don't want to have to care about Adobe's opinion about whether I should be able to do that or not. The way forward was obvious; I'd have to make my own Flash player. The plan Here's how Hapland works. There's a tree of sprites. In Flash, animated sprites can have code attached to certain frames, which runs when the playhead gets there. Hapland uses this a lot. The walk paths of the game characters are all just big long timeline animations, and characters often have frame actions saying, when you get to the door, open it if it's closed, or when you get to the landmine, trigger it if it hasn't exploded yet. [action]The little "a"s in the timeline are frame actions. Fortunately, .fla files are just XML. I just had to parse this, export the relevant data to a simple custom format^1 and write a player to read it, draw the scene, handle input, and run the animations. I'd also have to do something about the ActionScript. Hapland would stay a Flash project, written and maintained in the Flash editor; only Flash Player would be replaced. Rasterizing vectors Flash is all about vector graphics. It does support bitmaps, but it's really designed for vectors. That's how Flash movies managed to load so fast even on dialup connections, back in the day. All the Hapland graphics are vectors. GPUs don't really like drawing vector graphics. They like big batches of textured triangles. So, I needed to rasterize these vectors. I decided to rasterize them offline and pack the raster files into the game. It would have been fun to have the game rasterize them at runtime, and have it be this tiny executable, but I didn't want to have those extra moving parts. I like having as much of my code as possible run on my own dev machine, where I can keep an eye on it. Flash stores its vector graphics in XML format. You might argue that XML is a poor choice for graphics data, but you weren't a product manager at Macromedia. Behold: [guyxml]Vector data as seen in a .fla file Hey, I'm not complaining, it makes my job easier. Even though I didn't have access to a spec, rasterizing this wasn't such a hard problem. The bezier-spline model of vector graphics is all-pervasive, ever since PostScript. All these APIs work the same way. After a bit of trial and error to figure out what ! and [ and so on meant^2, I wrote a program to parse these shape definitions and render them out to PNGs using the Mac's CoreGraphics library. CoreGraphics was a dubious choice. I picked it because I was working on a Mac, and it was there, and dependencies are hard. But that did make it so I always had to rasterize the graphics on a Mac, even for Windows builds. If I do this kind of thing again I might pick a cross-platform library. After rendering these PNGs, the exporter then assembles them into atlases, quite naively. It just sorts everything by height and lays it all out row by row like text in a document. This is far from optimal but it's good enough. To keep things simple, the atlases are 2048x2048 pixels, the minimum required texture size that OpenGL 3.2 implementations have to support. [atlas]An atlas from Hapland 3 Rasterizing the shapes is pretty slow, so to keep build times reasonable I needed to skip rendering things that hadn't changed. The zipped XML format that Flash uses does have last-modified fields for each file, but Flash doesn't seem to use them properly and so you can't rely on them.^3 Instead, I just hash the XML of each shape and only rebuild if it's changed. Even that fails, because Flash sometimes likes to rearrange the XML tags in objects that haven't changed, but again, it's good enough. Using an assembler to write binary files The exporter writes the animation data to a custom binary format^4. It just goes frame by frame through the timeline and writes out all the changes for each frame. One thing I came up with here, which I rather like, is the idea of writing to an assembly listing rather than straight to a binary file. There's no CPU instructions, just data. This makes debugging easier, because I can look through the assembly file to see what was generated, rather than picking through bytes in a hex editor. output.bin 13 92 49 EC : BD 31 E8 FF 09 DD BE DE : C9 5A 1D 36 3F C0 4E 31 : 52 FD 41 C6 8B 5D C0 20 : 19 1F 5F 1F 54 97 8C 27 : 34 1F 30 EA A9 A9 E0 55 : 40 29 A3 19 89 BC 5F 24 : 3A 98 FD B9 DE 15 F2 D4 : 2A B7 41 2C 4E 9D 37 D9 : E2 13 4B 01 36 3F 40 08 : AC 3C FF 84 E9 AE C5 2C : 11 2F 69 CF 63 CE 85 D1 : A7 CB B1 1A 5F 5B 60 1A : 77 99 71 B0 60 6E C4 C7 : 73 1F EA 1F 31 0D 0C 39 : B0 86 70 42 output.asm ; Left Side timeline_132: ; --- Left Side, Frame 0 --- .frame_0: ; --- Left Side, Frame 0, Layer 22 --- db Quad dd 0.152926, 0.162029, 0.184475, 1.000000 ; color dd 799.599976, -20.950001 dd 799.599976, 556.650024 dd 46.000000, 556.650024 dd 46.000000, -20.950001 ; --- Left Side, Frame 0, Layer 21 --- ; instance of shape [Left Side] [Wall Shadows] [Frame 0] dd Shape dw 1560 Which would you rather debug? I could have just had the exporter write out the bytes to one file and a separate text listing to another file at the same time, without using the assembler, but I didn't do that because 1) assemblers already exist, 2) I don't have to debug them and 3) they support labels.^5 The rest of the exporter is mostly not interesting; it just walks the tree and converts things like transformation matrices, colour effects, and so on as it finds them. Onward, then, to the game program itself. I chose to write this in C++ because I already know it and new things scare me. Scene graph Hapland is a great fit for a scene graph^6. This is the model that Flash used and Hapland was designed around it, so there was no sense in trying to use a different model. I store the scene in memory as a boring tree of Nodes, each of which has a transform and can draw itself and accept mouse clicks. Each game object with behaviour of its own is an instance of its own class, deriving from Node. Object-orientation is not fashionable right now in gamedev circles, but I'm using Flash so I clearly don't care about that. Flash features that Hapland used, like colour transforms and masking, are present, although instead of implementing arbitrary masking like Flash did, I just implemented rect clipping and edited all my graphics so all the masks were rectangles. Frame scripts Almost all of the Hapland logic is in bits of ActionScript attached to timeline frames. How was I going to export all this? I didn't want to include an ActionScript interpreter in my game. [fa0]A simple frame action In the end, I settled for a bit of a hack. My exporter reads the ActionScript from each frame and applies a bunch of regexes to attempt to turn it into C++. For instance, crate.lid.play() might become crate()->lid()->play();. The two languages are similar enough syntactically that this works out fine for a lot of the simpler frame actions, but it still left a fair bit of broken code, and there was nothing for it but to go in and manually rewrite all the remaining frame actions. With all the frame scripts in C++, they are extracted at build time and become methods on each symbol's Node subclass. A dispatch method is also generated to call them at the right time, which looks something like this: void tick() override { switch (currentFrame) { case 1: _frame_1(); break; case 45: _frame_45(); break; case 200: _frame_200(); break; } } One last thing I'll call out here is that the scripting system ended up being sort of statically typed, which was kind of neat, since ActionScript wasn't^7. The game objects spit out by the exporter look like this: struct BigCrate: Node { BigCrateLid *lid() { return (BigCrateLid *)getChild("lid"); } BigCrateLabel *label() { return (BigCrateLabel *)getChild ("label"); } void swingOpen() { ... } void snapShut() { ... } void burnAway() { ... } }; So, even though everything was still a sea of string name lookups under the hood, the type-safe veneer would stop you from calling the wrong functions on the wrong objects, saving you from that annoying class of bug you get in dynamic languages where you typo something and only find out about it at runtime. Aspect ratios Ah, aspect ratios. Everybody who converts old media to new formats loves these. The original games were browser games that weren't even designed to run in full screen, so they just used whatever aspect ratio I felt like. Each game was different, but they were all around 3:2. The most common aspect ratio today seems to be 16:9, with 16:10 also popular on laptops. I wanted the game to look good at either of these without any black bars or stretching. The only ways to do this are either to cut bits off the originals, or to add bits on. So, I drew two rectangles in each game, one proportioned at 16:9 and the other at 16:10. The game then interpolates between them based on the screen's aspect ratio, and uses the interpolated rectangle as the camera view bounds. As long as all important game elements are inside the intersection of these rectangles, and their common bounding rectangle doesn't go off the edge of the scene, this works great. [arbox]16:10 and 16:9 boxes for Hapland 2, against the original 3:2. The only difficult part here was adapting the scenes themselves to accommodate the extra width; lots of things needed redrawing and rearranging to fit the new aspect ratios, which was a bit of a pain, but I got it all done in the end. Colour space nightmare After a bit of testing, I discovered that Flash does its alpha blending and colour transforms in perceptual space, not linear space. This is mathematically dubious, but on the other hand I get it; a lot of drawing programs work like this, and you want your consumer tool to work the way people expect, even if it annoys those head-in-the-clouds mathematicians who don't know anything about business. But on the first hand again, it's wrong! It causes problems with things like antialiasing. When you rasterize vector graphics, and you ask for antialiased output, your rasterizer will output alpha values that are so-called "coverage values". This means that if a given pixel is half-covered by the vector shape, that pixel will be output with alpha=0.5. But in Flash, when something has alpha of 0.5, that means it is perceptually halfway between the foreground and background colours. This is not the same thing!^8 A half-coverage white pixel drawn on top of an opaque black pixel should not be a perceptual 50% grey. That isn't how light works, and it isn't how vector rasterization can ever work. (A rasterizer can't say "this pixel should be perceptually X% between background and foreground colour" without knowing the background colour.) [greys-srgb]Blending done in perceptual (sRGB) space. Top: Transparent whites on black; Middle: Transparent blacks on white; Bottom: Greys [greys-line]The same blending done in linear (physically accurate) space. Note that 50% coverage does not look the same as 50% grey. So, we have our antialiased rasterized shapes using one definition of alpha, and our Flash-exported alpha transparency, gradients and colour transforms using another. But we only have one alpha channel in our rendering pipeline. So how should the renderer interpret alpha values? If it interprets them as perceptual blending factors, the semi-transparent objects will look right but the antialiased edges of everything will look wrong. If it interprets them as coverage values, the inverse will be true. Something will always look wrong! There are I think only two rigorous solutions here: 1) have two alpha channels, one for coverage and one for perceptual blending, or 2) rasterize all shapes without AA, draw everything to a very large framebuffer, and then scale it down with filtering.^9 I must admit, I didn't do either of these. I just accepted that semi-transparent things look different in Flash and in the game, and tweaked the graphics incrementally until the game looked good. Transparent objects were never going to look exactly how I designed them in Flash, but there aren't very many of them and it just isn't a big problem. To make sure I'd got everything else right, I made a "colour test" graphic with a bunch of colours of different intensities, hue rotation effects^10, and so on, had the game show it, and made sure it looked the same in the game as in Flash. [ctest]It's a match! Frame rate The original Flash games run at a nominal 24FPS, but really, they run at whatever frame rate Flash Player feels like running them at. With Flash, you might ask for 24FPS and get 15FPS, so then you'd ask for 30FPS and suddenly you'd get 24FPS. It was kind of silly. I wanted 60FPS for the remake, which meant doing something about the fact that the Hapland animations were authored expecting to be played back at about 24FPS. (Flash's animation tools are based around discrete frames, not continuous time.) I started by having my exporter double all the frames. So, for each timeline frame, it exports two frames^11. This gets us easily from 24FPS to 48FPS, but that still isn't quite 60, so animations were still going to run 25% faster. The solution was good old-fashioned elbow grease. I just played through the games and manually added in extra frames to animations that now seemed too quick. I now had a pretty good C++ conversion of the Hapland games that would surely run on contemporary computers for at least another decade or two. But I just couldn't shake the feeling that I should try to deliver a bit of extra value, so I added a couple of things. Aside from redrawing a lot of the old graphics and animations, I made a couple of major changes. Save states This was an idea I came up with to make Hapland 3 a bit less overwhelming. The correct path in that game is quite long, and there are many ways to screw it up and have to start all over again. Maybe this was fun in 2006 but we're grownups now, we haven't got time for that. Save states are something that emulators have. You press "save state", and it remembers the entire state of the game by dumping the console's memory to a file. Then, if you mess up, you press "load state" and you're back exactly where you were to try again.^12 It wouldn't have been feasible to implement save states in the original Flash games, because Flash doesn't give the programmer access to its whole state. But since I'm using all my own code this time, it is possible. I have this thing called a Zone, which is just an allocator that allocates all its memory inside a fixed-size chunk. All scene nodes are allocated inside the current Zone. To implement save and restore, I simply have two Zones, the active zone and a separate "save state zone". To save a state, I memcpy the active zone to the save state zone. To load a state, I memcpy back the other way. Second Quests The Hapland games aren't particularly long, and although there are three of them, I still wanted to give people a few more hours of play time. I decided to give each game a "Second Quest"--a modified version of the game where the layout and puzzles are slightly different. Making such a Second Quest is less work than making a whole new game, but still delivers some extra value. Creating the second quests meant digging back into Flash puzzle game development for the first time in about fifteen years, and, honestly, I quite liked it. The vintage Flash UI is great. Buttons have edges. Icons look like things. Space is well-used. It's amazing! Using old UIs makes me feel like an archaeologist discovering some sort of forgotten Roman technology. The lost art of UI design. It's neat. [tools]What is this wizardry? And although Flash is buggy, slow, and missing extremely basic features, I mostly didn't hate using it. I certainly don't know of a contemporary program that I'd have preferred to use. To stop the Second Quests looking too similar to the First Quests, they get new backgrounds, and the entire scene is also flipped horizontally. [hap3c]Hapland 3 [hap3c2]Hapland 3 Second Quest Music I knocked together a quick ambient soundtrack for each game, using stock sounds and a couple of recordings I made. Once when I was on holiday in Japan I took a field recording at the top of a hill for no reason in particular and it was nice to be able to use it for something. I hired a musician from the Internet to do the title screen music, and recorded some guitar chords myself for the end credits, drowned in effects so you can't tell I'm bad at guitar. I use Logic or Live for music, depending. I find Logic better for recording, and Live better for sound design. Achievements I get the feeling players expect achievements in Steam games. This is annoying because it should be up to the game designer whether achievements are an appropriate grumble grumble, but it's not a big deal. Uploading achievements to Steam is a pain. You can't just define a list and give it to their command-line tools; you have to laboriously click through the slow, confusing miasma of PHP sadness that is the Steam partner site and add them one by one. I think if you're a big important game studio you don't have to stand for that and they give you a bulk upload tool, but I'm not one of those so I looked at the HTTP calls it was making, saved my login cookie to a file and wrote my own. After changing my mind several times, I opted for a modest set of achievements: one for finishing each Hapland game, one for each Second Quest, and a couple for the bigger secrets. Any silly, obscure secrets that nobody will ever find do not have achievements; you just get the satisfaction of seeing what happens. [ach]Achievements in the Steamworks UI Notarization Although I developed the game mostly on my Mac, during development Apple invented this thing called "Notarization" where if you run any app on a new version of MacOS, it'll make a network request to Apple to ask if the app's developer pays Apple a yearly fee. If the developer does not pay Apple a yearly fee, MacOS will pop up a dialog strongly implying the app is a virus and refuse to start it. For this reason, Windows will be the first and maybe only release platform for this game. Libraries used For software that I'm shipping to end-users, I like to keep dependencies to a minimum, but I'm happy to use a few high-quality ones. Other than OpenGL and standard operating system stuff, this is the full list of libraries that the Hapland Trilogy executable ended up linking to: * Steam SDK * cute_sound * stb_vorbis * stb_image The end And that's it! Thank you for coming on this blog post adventure with me. It's been fun. If you do the tech stuff right, players don't notice it at all, so sometimes you just want to say, hey, look, look what I made. If you'd like to read other things I've written, then you're out of luck because I don't tend to post much writing online. If you'd like to support me, you could buy Hapland Trilogy or my other Steam game from 2020, Blackshift. If you want to play some of my games without paying, I have a bunch of free browser games on my website at foon.uk . The newest ones are Javascript or WASM, and the oldest ones, including the original Haplands, are AS2 Flash, which runs pretty well thanks to Ruffle. The later Flash games are AS3 so they don't run anymore. If you have any comments or just want to say hi you can email me at r@foon.uk, or if you're into Twitter, I'm @FoonGames. I've got an RSS feed too. Take care. Robin Allen 2022 1. It was tempting to just use SWF, the format that Flash already knows how to export to, but this didn't work out because SWF is a vector format and I didn't want to do rasterization at runtime, and also because it limits your options for what to do with the scripts, and for other reasons that I can't remember. 2. !: Move to point. | or /: Straight line to point. [ Bezier segment to point. S: No idea. 3. That figures, too -- Flash takes an age to save large documents, even if you only changed a single thing, so it probably doesn't track last-modified times at all. 4. I like to use binary formats where possible for end-user software; I don't believe in making customers' computers chew through reams of XML. 5. It's often the case when writing binary files that you need to write the location of one bit of data into another bit of data, and these two locations could be in either order in the file. It's just so easy to be able to write out dd mylabel at one end and mylabel: at the other and let the assembler deal with it. 6. Blackshift was not. 7. ActionScript 3 started moving in that direction, but the Haplands were ActionScript 2 games. 8. Most other image editors also work the same way. Some now have a "do things correctly" button but it's rarely the default. If you want a fun rabbit-hole to go down, look up how most fonts are thinner than they should be because they're designed to be displayed by programs that do alpha blending wrong. 9. This raises the question of what Flash does. The Flash renderer is working with the vector data directly, without a separate rasterization step, so it might be able to do antialiasing directly in perceptual space as it renders, since it can check the background colour. Or maybe it just uses full-screen supersampling. 10. Flash's colour transform effects (hue rotations, brightness adjustments, etc.) are expressed as 5x4 matrices in sRGB space, and it's easy enough to sort out the sRGB/Linear problem for these. For simple matrices, you can just convert them, and for the others, simply export the sRGB matrix and have the game compute srgb_to_linear(ctr * linear_to_srgb(c)) in a fragment shader. It's a few extra GPU cycles but it still all runs fine. 11. The frame doubler is smart enough to interpolate object positions for frames that are part of motion tweens, so those stay smooth. 12. Some people had asked for an "undo button" but, say you fire a projectile, then quickly knock it with something when it's flying, then hit undo, what should happen? It's a case-by-case design decision. And there are a lot of cases. With save states, I don't have to make this judgement call. (c) 2022 Robin Allen * foon.uk